From 5ddc2d1d24ded9a9ba050732f3c958edcaf492ab Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 3 May 2026 16:26:43 +0200 Subject: [PATCH 001/121] refactored the simplest issues: removing uncommented auto-generated code removing string literals and redefining them in constants --- .../frontend/ui/screens/LobbyScreen.kt | 92 ++++++++++--------- app/src/main/res/xml/backup_rules.xml | 5 +- .../main/res/xml/data_extraction_rules.xml | 12 +-- 3 files changed, 52 insertions(+), 57 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 52e2a9f..78df774 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 @@ -24,49 +24,55 @@ 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.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles -@Suppress("ktlint:standard:function-naming") -@Composable -fun LobbyScreen(navController: NavHostController) { - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) +val blueGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), + ) + +val redGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFCF5530), + Color(0xFFDE8468), + ), + ) - val redGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFFCF5530), - Color(0xFFDE8468), - ), - ) +val brownGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF383330), + Color(0xFF1A1513), + ), + ) - val brownGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF383330), - Color(0xFF1A1513), - ), - ) +val greenGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF4CAF50), + Color(0xFF2E7D32), + ), + ) - val greenGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), - ) +const val join_team : String = "JOIN TEAM" +const val team_joined : String = "👤 1 joined" + +@Suppress("ktlint:standard:function-naming") +@Composable +fun LobbyScreen(navController: NavHostController) { var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } @@ -112,14 +118,14 @@ fun LobbyScreen(navController: NavHostController) { if (currentRole == PlayerRoles.BLUE_OPERATIVE) { Text( - text = "👤 1 joined", + text = team_joined, color = Color.White, modifier = Modifier.padding(vertical = 8.dp), ) } AppButton( - text = "JOIN TEAM", + text = join_team, onClick = { currentRole = PlayerRoles.BLUE_OPERATIVE }, style = AppButtonStyle( @@ -146,14 +152,14 @@ fun LobbyScreen(navController: NavHostController) { if (currentRole == PlayerRoles.BLUE_SPYMASTER) { Text( - text = "👤 1 joined", + text = team_joined, color = Color.White, modifier = Modifier.padding(vertical = 8.dp), ) } AppButton( - text = "JOIN TEAM", + text = join_team, onClick = { currentRole = PlayerRoles.BLUE_SPYMASTER }, style = AppButtonStyle( @@ -259,14 +265,14 @@ fun LobbyScreen(navController: NavHostController) { if (currentRole == PlayerRoles.RED_OPERATIVE) { Text( - text = "👤 1 joined", + text = team_joined, color = Color.White, modifier = Modifier.padding(vertical = 8.dp), ) } AppButton( - text = "JOIN TEAM", + text = join_team, onClick = { currentRole = PlayerRoles.RED_OPERATIVE }, style = AppButtonStyle( @@ -293,14 +299,14 @@ fun LobbyScreen(navController: NavHostController) { if (currentRole == PlayerRoles.RED_SPYMASTER) { Text( - text = "👤 1 joined", + text = team_joined, color = Color.White, modifier = Modifier.padding(vertical = 8.dp), ) } AppButton( - text = "JOIN TEAM", + text = join_team, onClick = { currentRole = PlayerRoles.RED_SPYMASTER }, style = AppButtonStyle( diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..25b13fd 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,8 +6,5 @@ See https://developer.android.com/about/versions/12/backup-restore --> - + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..befb127 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,15 +5,7 @@ --> - + - + \ No newline at end of file From e815cafba9455dae9afc521a097f9e7887eb742f Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 3 May 2026 17:30:01 +0200 Subject: [PATCH 002/121] refactoring of lobby screen --- .../frontend/ui/screens/LobbyScreen.kt | 377 ++++++++---------- 1 file changed, 164 insertions(+), 213 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 78df774..6978248 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 @@ -23,8 +23,8 @@ 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.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -67,8 +67,8 @@ val greenGradient = ), ) -const val join_team : String = "JOIN TEAM" -const val team_joined : String = "👤 1 joined" +private const val JOIN_TEAM : String = "JOIN TEAM" +private const val TEAM_JOINED : String = "👤 1 joined" @Suppress("ktlint:standard:function-naming") @Composable @@ -84,237 +84,188 @@ fun LobbyScreen(navController: NavHostController) { horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "BLUE TEAM", - color = Color(0xFF42A5F5), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - modifier = - Modifier - .align(Alignment.Start) - .padding(start = 6.dp) - .padding(bottom = 6.dp), - ) - Column( - modifier = - Modifier - .align(Alignment.Start) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(blueGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("OPERATIVES", color = Color.White, fontWeight = FontWeight.Bold) + TeamColumn( + modifier = Modifier.weight(1f), + color = Team.BLUE, + gradient = blueGradient, + textColor = Color(0xFF42A5F5), + title = "BLUE TEAM", + currentRole = currentRole, + onRoleSelect = { currentRole = it } + ) - if (currentRole == PlayerRoles.BLUE_OPERATIVE) { - Text( - text = team_joined, - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } + GameSettingsColumn( + currentRole = currentRole, + navController = navController + ) - AppButton( - text = join_team, - onClick = { currentRole = PlayerRoles.BLUE_OPERATIVE }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } + TeamColumn( + modifier = Modifier.weight(1f), + color = Team.RED, + gradient = redGradient, + textColor = Color(0xFFDE8468), + title = "RED TEAM", + currentRole = currentRole, + onRoleSelect = { currentRole = it } + ) + } +} - Column( - modifier = - Modifier - .align(Alignment.Start) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(blueGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("SPYMASTERS", color = Color.White, fontWeight = FontWeight.Bold) +@Composable +fun TeamColumn( + modifier: Modifier, + color: Team, + gradient: Brush, + textColor: Color, + title: String, + currentRole: PlayerRoles, + onRoleSelect: (PlayerRoles) -> Unit +){ + val align = if (color == Team.RED) Alignment.End else Alignment.Start + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val cardModifier = Modifier + .align(align) + .width(200.dp) + .height(150.dp) + .fillMaxWidth(0.5f) + .padding(start = 6.dp, bottom = 12.dp) + .background(gradient, RoundedCornerShape(12.dp)) + .padding(12.dp) - if (currentRole == PlayerRoles.BLUE_SPYMASTER) { - Text( - text = team_joined, - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } + Text( + text = title, + color = textColor, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(align) + .padding(start = 6.dp) + .padding(end = 6.dp) + .padding(bottom = 6.dp), + ) - AppButton( - text = join_team, - onClick = { currentRole = PlayerRoles.BLUE_SPYMASTER }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } - } + RoleCard( + role = if(color == Team.RED) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE, + currentRole = currentRole, + onRoleSelect = onRoleSelect, + modifier = cardModifier, + title = "OPERATIVES" + ) - Column( - modifier = Modifier.padding(horizontal = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(250.dp) - .fillMaxWidth(0.5f) - .background(brownGradient, RoundedCornerShape(12.dp)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - ) { - Text( - text = "GAME SETTINGS", - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp), - ) + RoleCard( + role = if(color == Team.RED) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER, + currentRole = currentRole, + onRoleSelect = onRoleSelect, + modifier = cardModifier, + title = "SPYMASTERS" + ) + } +} - AppButton( - text = "TIMER: OFF", - onClick = { /* TODO: Timer Logik */ }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - style = - AppButtonStyle( - containerColor = Color(0xFF555555), - contentColor = Color.White, - fontSize = 18.sp, - ), - ) - } +@Composable +fun RoleCard( + role: PlayerRoles, + currentRole: PlayerRoles, + onRoleSelect: (PlayerRoles) -> Unit, + modifier: Modifier, + title: String +){ + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, color = Color.White, fontWeight = FontWeight.Bold) - AppButton( - text = "START GAME", - onClick = { - navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") - }, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(70.dp) - .fillMaxWidth(0.5f) - .padding(top = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 28.sp, - lineHeight = 30.sp, - ), + if (currentRole == role) { + Text( + text = TEAM_JOINED, + color = Color.White, + modifier = Modifier.padding(vertical = 8.dp), ) } + AppButton( + text = JOIN_TEAM, + onClick = { onRoleSelect(role) }, + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 16.sp, + ), + ) + } +} + +@Composable +fun GameSettingsColumn( + currentRole: PlayerRoles, + navController: NavController +){ + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(400.dp) + .height(250.dp) + .fillMaxWidth(0.5f) + .background(brownGradient, RoundedCornerShape(12.dp)) + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, ) { Text( - text = "RED TEAM", - color = Color(0xFFDE8468), - fontSize = 24.sp, + text = "GAME SETTINGS", + color = Color.White, fontWeight = FontWeight.Bold, - modifier = - Modifier - .align(Alignment.End) - .padding(end = 6.dp) - .padding(bottom = 6.dp), + modifier = Modifier.padding(bottom = 16.dp), ) - Column( - modifier = - Modifier - .align(Alignment.End) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(end = 6.dp, bottom = 12.dp) - .background(redGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("OPERATIVES", color = Color.White, fontWeight = FontWeight.Bold) - - if (currentRole == PlayerRoles.RED_OPERATIVE) { - Text( - text = team_joined, - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - - AppButton( - text = join_team, - onClick = { currentRole = PlayerRoles.RED_OPERATIVE }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } - - Column( + AppButton( + text = "TIMER: OFF", + onClick = { /* TODO: Timer Logik */ }, modifier = Modifier - .align(Alignment.End) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(end = 6.dp, bottom = 12.dp) - .background(redGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("SPYMASTERS", color = Color.White, fontWeight = FontWeight.Bold) - - if (currentRole == PlayerRoles.RED_SPYMASTER) { - Text( - text = team_joined, - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - - AppButton( - text = join_team, - onClick = { currentRole = PlayerRoles.RED_SPYMASTER }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } + .fillMaxWidth() + .padding(bottom = 8.dp), + style = + AppButtonStyle( + containerColor = Color(0xFF555555), + contentColor = Color.White, + fontSize = 18.sp, + ), + ) } + + AppButton( + text = "START GAME", + onClick = { + navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") + }, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(400.dp) + .height(70.dp) + .fillMaxWidth(0.5f) + .padding(top = 12.dp), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 28.sp, + lineHeight = 30.sp, + ), + ) } } From d77693e8237a5ec07e763e0ed4c2aca19ad86ece Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 3 May 2026 19:24:46 +0200 Subject: [PATCH 003/121] refactoring of game screen >> extracted grid in separate kotlin file >> extracted visual parts in separate methods --- .../frontend/ui/composables/GameBoardGrid.kt | 72 ++++ .../frontend/ui/screens/GameboardScreen.kt | 326 ++++++++---------- 2 files changed, 219 insertions(+), 179 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt diff --git a/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt b/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt new file mode 100644 index 0000000..e8874aa --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt @@ -0,0 +1,72 @@ +package com.codenames.frontend.ui.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.codenames.frontend.ui.screens.CodenamesCard +import com.codenames.frontend.ui.screens.GameCard + +@Composable +fun GameBoardGrid( + cards: List, + scale: Float, + offset: Offset, + isSpymaster: Boolean, + onReveal: (Int)->Unit, + modifier: Modifier +){ + Box( + modifier = modifier + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val columns = 5 + for (i in cards.indices step columns) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (j in 0 until columns) { + val index = i + j + if (index < cards.size) { + val card = cards[index] + Box(modifier = Modifier.weight(1f)) { + CodenamesCard( + card = card, + isSpymaster = isSpymaster, + onClick = { + if (!isSpymaster && !card.revealed) { + onReveal(index) + } + }, + ) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt index 2650692..194a20f 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.Text @@ -29,18 +28,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.composables.GameBoardGrid import com.codenames.frontend.ui.inputs.AppTextField import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard import com.codenames.frontend.ui.inputs.AppTextFieldState @@ -64,6 +66,7 @@ data class GameCard( fun GameTestScreen() { var currentHint by remember { mutableStateOf("Waiting for hint...") } + //will be replaced by backend call val cards = remember { mutableStateListOf( @@ -128,15 +131,6 @@ fun GameboardScreen( var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } - val blueGradient = - Brush.verticalGradient( - colors = listOf(Color(0xFF42A5F5), Color(0xFF1565C0)), - ) - val redGradient = - Brush.verticalGradient( - colors = listOf(Color(0xFFCF5530), Color(0xFFDE8468)), - ) - Column( modifier = modifier @@ -149,183 +143,157 @@ fun GameboardScreen( .weight(1f) .fillMaxWidth(), ) { - Column( - modifier = - Modifier - .width(90.dp) - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "BLUE TEAM", - fontWeight = FontWeight.Bold, - color = Color(0xFF1565C0), - fontSize = 12.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) + TeamSidebar( + userRole, + color = Team.BLUE, + teamLeft = blueLeft, + textColor = Color(0xFF1565C0), + gradient = blueGradient + ) - TeamRoleBox( - title = "OPERATIVES", - gradient = blueGradient, - isCurrentUser = userRole == PlayerRoles.BLUE_OPERATIVE, - ) - Spacer(modifier = Modifier.height(8.dp)) - TeamRoleBox( - title = "SPYMASTERS", - gradient = blueGradient, - isCurrentUser = userRole == PlayerRoles.BLUE_SPYMASTER, - ) + GameBoardGrid( + cards, + scale, + offset, + isSpymaster, + onReveal, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .clipToBounds() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + offset += pan + } + }, + ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "$blueLeft LEFT", - color = Color(0xFF1565C0), - fontWeight = FontWeight.ExtraBold, - fontSize = 18.sp, - ) - } + TeamSidebar( + userRole, + color = Team.RED, + teamLeft = redLeft, + textColor = Color(0xFFCF5530), + gradient = redGradient + ) + } - Box( - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = 8.dp) - .clipToBounds() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(0.5f, 3f) - offset += pan - } - }, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight(unbounded = true) - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offset.x, - translationY = offset.y, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val columns = 5 - for (i in cards.indices step columns) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (j in 0 until columns) { - val index = i + j - if (index < cards.size) { - val card = cards[index] - Box(modifier = Modifier.weight(1f)) { - CodenamesCard( - card = card, - isSpymaster = isSpymaster, - onClick = { - if (!isSpymaster && !card.revealed) { - onReveal(index) - } - }, - ) - } - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - } - } - } + Spacer(modifier = Modifier.height(12.dp)) - Column( - modifier = - Modifier - .width(90.dp) - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "RED TEAM", - fontWeight = FontWeight.Bold, - color = Color(0xFFCF5530), - fontSize = 12.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) + HintSection( + isSpymaster, + currentHint, + hintInput, + onHintChange, + keyboardController, + focusManager + ) + } +} - TeamRoleBox( - title = "OPERATIVES", - gradient = redGradient, - isCurrentUser = userRole == PlayerRoles.RED_OPERATIVE, - ) - Spacer(modifier = Modifier.height(8.dp)) - TeamRoleBox( - title = "SPYMASTERS", - gradient = redGradient, - isCurrentUser = userRole == PlayerRoles.RED_SPYMASTER, - ) +@Composable +fun TeamSidebar( + userRole: PlayerRoles, + color: Team, + teamLeft: Int, + textColor: Color, + gradient: Brush +){ + val isRed = color == Team.RED + val operative = if (isRed) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE + val spymaster = if (isRed) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER + val title = if (isRed) "RED TEAM" else "BLUE TEAM" + Column( + modifier = + Modifier + .width(90.dp) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + title, + fontWeight = FontWeight.Bold, + color = textColor, + fontSize = 12.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "$redLeft LEFT", - color = Color(0xFFCF5530), - fontWeight = FontWeight.ExtraBold, - fontSize = 18.sp, - ) - } - } + TeamRoleBox( + title = "OPERATIVES", + gradient = gradient, + isCurrentUser = userRole == operative, + ) + Spacer(modifier = Modifier.height(8.dp)) + TeamRoleBox( + title = "SPYMASTERS", + gradient = gradient, + isCurrentUser = userRole == spymaster, + ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "$teamLeft LEFT", + color = textColor, + fontWeight = FontWeight.ExtraBold, + fontSize = 18.sp, + ) + } +} - if (isSpymaster) { - Row { - AppTextField( - value = hintInput, - onValueChange = { hintInput = it }, - modifier = Modifier.weight(1f), - state = - AppTextFieldState( - label = "HINT", - placeholder = "Enter word...", - ), - keyboard = - AppTextFieldKeyboard( - actions = - KeyboardActions( - onSend = { - if (hintInput.isNotBlank()) { - onHintChange(hintInput.uppercase()) - hintInput = "" - focusManager.clearFocus() - keyboardController?.hide() - } - }, - ), - ), - ) +@Composable +fun HintSection( + isSpymaster: Boolean, + currentHint: String, + hintInput: String, + onHintChange: (String) -> Unit, + keyboardController: SoftwareKeyboardController?, + focusManager: FocusManager +){ + if (isSpymaster) { + Row { + AppTextField( + value = hintInput, + onValueChange = onHintChange, + modifier = Modifier.weight(1f), + state = + AppTextFieldState( + label = "HINT", + placeholder = "Enter word...", + ), + keyboard = + AppTextFieldKeyboard( + actions = + KeyboardActions( + onSend = { + if (hintInput.isNotBlank()) { + onHintChange(hintInput.uppercase()) + onHintChange("") + focusManager.clearFocus() + keyboardController?.hide() + } + }, + ), + ), + ) - AppButton( - text = "SEND", - onClick = { - if (hintInput.isNotBlank()) { - onHintChange(hintInput.uppercase()) - hintInput = "" - } - }, - ) - } - } else { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text( - text = "Hint: $currentHint", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - ) - } + AppButton( + text = "SEND", + onClick = { + if (hintInput.isNotBlank()) { + onHintChange(hintInput.uppercase()) + onHintChange("") + } + }, + ) + } + } else { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = "Hint: $currentHint", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) } } } From 5810fa8300a812a7e1c7d89fd372fbc294079683 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 3 May 2026 19:35:09 +0200 Subject: [PATCH 004/121] formatted code to fit ktlint standards --- .../frontend/ui/composables/GameBoardGrid.kt | 11 ++-- .../frontend/ui/screens/GameboardScreen.kt | 41 ++++++++------- .../frontend/ui/screens/LobbyScreen.kt | 52 ++++++++++--------- 3 files changed, 55 insertions(+), 49 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 e8874aa..5a0fcbd 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 @@ -15,17 +15,18 @@ import androidx.compose.ui.unit.dp import com.codenames.frontend.ui.screens.CodenamesCard import com.codenames.frontend.ui.screens.GameCard +@Suppress("ktlint:standard:function-naming") @Composable fun GameBoardGrid( cards: List, scale: Float, offset: Offset, isSpymaster: Boolean, - onReveal: (Int)->Unit, - modifier: Modifier -){ + onReveal: (Int) -> Unit, + modifier: Modifier, +) { Box( - modifier = modifier + modifier = modifier, ) { Column( modifier = @@ -69,4 +70,4 @@ fun GameBoardGrid( } } } -} \ No newline at end of file +} 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 194a20f..dd1060b 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 @@ -66,7 +66,7 @@ data class GameCard( fun GameTestScreen() { var currentHint by remember { mutableStateOf("Waiting for hint...") } - //will be replaced by backend call + // will be replaced by backend call val cards = remember { mutableStateListOf( @@ -148,7 +148,7 @@ fun GameboardScreen( color = Team.BLUE, teamLeft = blueLeft, textColor = Color(0xFF1565C0), - gradient = blueGradient + gradient = blueGradient, ) GameBoardGrid( @@ -157,17 +157,18 @@ fun GameboardScreen( offset, isSpymaster, onReveal, - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = 8.dp) - .clipToBounds() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(0.5f, 3f) - offset += pan - } - }, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .clipToBounds() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + offset += pan + } + }, ) TeamSidebar( @@ -175,7 +176,7 @@ fun GameboardScreen( color = Team.RED, teamLeft = redLeft, textColor = Color(0xFFCF5530), - gradient = redGradient + gradient = redGradient, ) } @@ -187,19 +188,20 @@ fun GameboardScreen( hintInput, onHintChange, keyboardController, - focusManager + focusManager, ) } } +@Suppress("ktlint:standard:function-naming") @Composable fun TeamSidebar( userRole: PlayerRoles, color: Team, teamLeft: Int, textColor: Color, - gradient: Brush -){ + gradient: Brush, +) { val isRed = color == Team.RED val operative = if (isRed) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE val spymaster = if (isRed) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER @@ -241,6 +243,7 @@ fun TeamSidebar( } } +@Suppress("ktlint:standard:function-naming") @Composable fun HintSection( isSpymaster: Boolean, @@ -248,8 +251,8 @@ fun HintSection( hintInput: String, onHintChange: (String) -> Unit, keyboardController: SoftwareKeyboardController?, - focusManager: FocusManager -){ + focusManager: FocusManager, +) { if (isSpymaster) { Row { AppTextField( 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 6978248..24553ea 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 @@ -67,13 +67,12 @@ val greenGradient = ), ) -private const val JOIN_TEAM : String = "JOIN TEAM" -private const val TEAM_JOINED : String = "👤 1 joined" +private const val JOIN_TEAM: String = "JOIN TEAM" +private const val TEAM_JOINED: String = "👤 1 joined" @Suppress("ktlint:standard:function-naming") @Composable fun LobbyScreen(navController: NavHostController) { - var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } Row( @@ -84,7 +83,6 @@ fun LobbyScreen(navController: NavHostController) { horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - TeamColumn( modifier = Modifier.weight(1f), color = Team.BLUE, @@ -92,12 +90,12 @@ fun LobbyScreen(navController: NavHostController) { textColor = Color(0xFF42A5F5), title = "BLUE TEAM", currentRole = currentRole, - onRoleSelect = { currentRole = it } + onRoleSelect = { currentRole = it }, ) GameSettingsColumn( currentRole = currentRole, - navController = navController + navController = navController, ) TeamColumn( @@ -107,11 +105,12 @@ fun LobbyScreen(navController: NavHostController) { textColor = Color(0xFFDE8468), title = "RED TEAM", currentRole = currentRole, - onRoleSelect = { currentRole = it } + onRoleSelect = { currentRole = it }, ) } } +@Suppress("ktlint:standard:function-naming") @Composable fun TeamColumn( modifier: Modifier, @@ -120,22 +119,23 @@ fun TeamColumn( textColor: Color, title: String, currentRole: PlayerRoles, - onRoleSelect: (PlayerRoles) -> Unit -){ + onRoleSelect: (PlayerRoles) -> Unit, +) { val align = if (color == Team.RED) Alignment.End else Alignment.Start Column( modifier = modifier, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - val cardModifier = Modifier - .align(align) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(gradient, RoundedCornerShape(12.dp)) - .padding(12.dp) + val cardModifier = + Modifier + .align(align) + .width(200.dp) + .height(150.dp) + .fillMaxWidth(0.5f) + .padding(start = 6.dp, bottom = 12.dp) + .background(gradient, RoundedCornerShape(12.dp)) + .padding(12.dp) Text( text = title, @@ -151,31 +151,32 @@ fun TeamColumn( ) RoleCard( - role = if(color == Team.RED) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE, + role = if (color == Team.RED) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE, currentRole = currentRole, onRoleSelect = onRoleSelect, modifier = cardModifier, - title = "OPERATIVES" + title = "OPERATIVES", ) RoleCard( - role = if(color == Team.RED) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER, + role = if (color == Team.RED) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER, currentRole = currentRole, onRoleSelect = onRoleSelect, modifier = cardModifier, - title = "SPYMASTERS" + title = "SPYMASTERS", ) } } +@Suppress("ktlint:standard:function-naming") @Composable fun RoleCard( role: PlayerRoles, currentRole: PlayerRoles, onRoleSelect: (PlayerRoles) -> Unit, modifier: Modifier, - title: String -){ + title: String, +) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -203,11 +204,12 @@ fun RoleCard( } } +@Suppress("ktlint:standard:function-naming") @Composable fun GameSettingsColumn( currentRole: PlayerRoles, - navController: NavController -){ + navController: NavController, +) { Column( modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.Center, From 0f46065c70ada76a0f3a8d6d9a8cb567529f4124 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 3 May 2026 19:40:46 +0200 Subject: [PATCH 005/121] refactored GameBoardGrid --- .../frontend/ui/composables/GameBoardGrid.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 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 5a0fcbd..d3a4523 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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable @@ -15,6 +14,8 @@ import androidx.compose.ui.unit.dp import com.codenames.frontend.ui.screens.CodenamesCard import com.codenames.frontend.ui.screens.GameCard +const val BOARD_COLUMNS = 5 + @Suppress("ktlint:standard:function-naming") @Composable fun GameBoardGrid( @@ -41,17 +42,22 @@ fun GameBoardGrid( ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - val columns = 5 - for (i in cards.indices step columns) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (j in 0 until columns) { - val index = i + j - if (index < cards.size) { - val card = cards[index] - Box(modifier = Modifier.weight(1f)) { + cards + .chunked(BOARD_COLUMNS) + .forEachIndexed { rowIndex, row -> + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEachIndexed { columnIndex, card -> + + val index = + (rowIndex * BOARD_COLUMNS) + columnIndex + + Box( + modifier = Modifier.weight(1f), + ) { CodenamesCard( card = card, isSpymaster = isSpymaster, @@ -62,12 +68,9 @@ fun GameBoardGrid( }, ) } - } else { - Spacer(modifier = Modifier.weight(1f)) } } } - } } } } From 3946454b33ea5b50a5ce56316fad5162510d138d Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 3 May 2026 20:19:10 +0200 Subject: [PATCH 006/121] fix: hint can be entered and sent again --- .../codenames/frontend/ui/screens/GameboardScreen.kt | 10 +++++++--- 1 file changed, 7 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 dd1060b..0ac3e59 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 @@ -131,6 +131,8 @@ fun GameboardScreen( var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } + val onInputChange: (String) -> Unit = { hintInput = it } + Column( modifier = modifier @@ -187,6 +189,7 @@ fun GameboardScreen( currentHint, hintInput, onHintChange, + onInputChange, keyboardController, focusManager, ) @@ -250,6 +253,7 @@ fun HintSection( currentHint: String, hintInput: String, onHintChange: (String) -> Unit, + onInputChange: (String) -> Unit, keyboardController: SoftwareKeyboardController?, focusManager: FocusManager, ) { @@ -257,7 +261,7 @@ fun HintSection( Row { AppTextField( value = hintInput, - onValueChange = onHintChange, + onValueChange = onInputChange, modifier = Modifier.weight(1f), state = AppTextFieldState( @@ -271,7 +275,7 @@ fun HintSection( onSend = { if (hintInput.isNotBlank()) { onHintChange(hintInput.uppercase()) - onHintChange("") + onInputChange("") focusManager.clearFocus() keyboardController?.hide() } @@ -285,7 +289,7 @@ fun HintSection( onClick = { if (hintInput.isNotBlank()) { onHintChange(hintInput.uppercase()) - onHintChange("") + onInputChange("") } }, ) From 51b9f461c5a373393d3ecaa71fc6e07d3202775c Mon Sep 17 00:00:00 2001 From: XtophB Date: Tue, 5 May 2026 23:41:18 +0200 Subject: [PATCH 007/121] feat: add DTO to mirror backend chat DTO --- .../frontend/data/model/enums/ChatMessageType.kt | 8 ++++++++ .../codenames/frontend/network/dto/ChatMessageDto.kt | 12 ++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt create mode 100644 app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt new file mode 100644 index 0000000..e406ce1 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt @@ -0,0 +1,8 @@ +package com.codenames.frontend.data.model.enums + +import kotlinx.serialization.Serializable + +@Serializable +enum class ChatMessageType { + CHAT +} \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt new file mode 100644 index 0000000..099595c --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt @@ -0,0 +1,12 @@ +package com.codenames.frontend.network.dto + +import com.codenames.frontend.data.model.enums.ChatMessageType +import kotlinx.serialization.Serializable + + +@Serializable +data class ChatMessageDto( + val senderUsername: String, + val content: String, + val type: ChatMessageType = ChatMessageType.CHAT +) \ No newline at end of file From 3cd3a4f978d5e731db50f2fc761be63a3d077ad7 Mon Sep 17 00:00:00 2001 From: XtophB Date: Tue, 5 May 2026 23:52:54 +0200 Subject: [PATCH 008/121] feat: add chat model for UI and List to store the incoming chats --- .../com/codenames/frontend/data/model/ChatDomainModel.kt | 7 +++++++ .../java/com/codenames/frontend/data/model/ChatLists.kt | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt new file mode 100644 index 0000000..1d916f8 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt @@ -0,0 +1,7 @@ +package com.codenames.frontend.data.model + +data class ChatDomainModel ( + val sender: String, + val text: String, + val isFromMe: Boolean + ) \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt new file mode 100644 index 0000000..938d72d --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt @@ -0,0 +1,7 @@ +package com.codenames.frontend.data.model + +data class ChatLists ( + val lobbyMessages: List = emptyList(), + val teamMessages: List = emptyList(), + val operativeMessages: List = emptyList() +) \ No newline at end of file From 982c32d8d031c8960ad962f0b091ba81f1f769bd Mon Sep 17 00:00:00 2001 From: XtophB Date: Wed, 6 May 2026 01:17:47 +0200 Subject: [PATCH 009/121] feat: add subscribe and sending functionalities --- .../frontend/network/websocket/GameWebSocketHandler.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt index 22ae7c4..684e64d 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,5 +1,6 @@ package com.codenames.frontend.network.websocket +import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage @@ -37,4 +38,12 @@ class GameWebSocketHandler val lobbyCode = msg.lobbyCode session.convertAndSend("app/$lobbyCode/join", msg, WebSocketJoinMessage.serializer()) } + + suspend fun subscribeToChat(topicPath: String): Flow { + return session.subscribe(topicPath, ChatMessageDto.serializer()) + } + + suspend fun sendChatMessage(destination: String, msg: ChatMessageDto) { + session.convertAndSend(destination, msg, ChatMessageDto.serializer()) + } } From a34f3fa0108572fc55be43f8f076cf5f3aeb326b Mon Sep 17 00:00:00 2001 From: XtophB Date: Wed, 6 May 2026 01:29:01 +0200 Subject: [PATCH 010/121] feat: add mapping of continuous chat steam to domain model --- .../data/repository/ChatRepository.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.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 new file mode 100644 index 0000000..39f3721 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -0,0 +1,33 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class ChatRepository @Inject constructor( + private val webSocketHandler: GameWebSocketHandler +) { + fun observeChat(topic: String, currentUsername: String): Flow = + flow { + val subscriptionFlow = webSocketHandler.subscribeToChat(topic) + + subscriptionFlow.collect { dto -> + emit( + ChatDomainModel( + sender = dto.senderUsername, + text = dto.content, + isFromMe = dto.senderUsername == currentUsername + ) + ) + } + } + + suspend fun sendMessage(destination: String, username: String, text: String) { + val dto = ChatMessageDto(senderUsername = username, content = text) + webSocketHandler.sendChatMessage(destination, dto) + } +} \ No newline at end of file From eeaf105abf43dc173b64908a16957ed777bbc8af Mon Sep 17 00:00:00 2001 From: XtophB Date: Wed, 6 May 2026 01:31:04 +0200 Subject: [PATCH 011/121] feat: update ViewModel to listen to respective chats and update chat log --- .../frontend/viewmodel/GameViewModel.kt | 41 +++++++++++++++++++ 1 file changed, 41 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 4f5a3ee..1cd3608 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -2,7 +2,10 @@ package com.codenames.frontend.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.model.ChatLists import com.codenames.frontend.data.model.enums.ConnectionState +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.repository.ChatRepository import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler @@ -12,24 +15,31 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.flow.update @HiltViewModel class GameViewModel @Inject constructor( private val client: GameWebSocketHandler, + private val chatRepository: ChatRepository ) : ViewModel() { private var job: Job? = null private val _uiState = MutableStateFlow(GameMessage()) val uiState: StateFlow = _uiState + private val _chatState = MutableStateFlow(ChatLists()) + val chatState: StateFlow = _chatState + private val _connectionState = MutableStateFlow(ConnectionState.IDLE) val connectionState: StateFlow = _connectionState fun connect( username: String, lobbyCode: String, + team: String, + role: String ) { job?.cancel() @@ -47,6 +57,37 @@ class GameViewModel .subscribeToLobby(lobbyCode) .collect { handleMessage(it) } } + + launch { + chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> + val currentChatLists = _chatState.value + val updatedLobbyList = currentChatLists.lobbyMessages + msg + // we copy the entire ChatList object and update the entire list of where we are listening to then return the entire object + val newState = currentChatLists.copy(lobbyMessages = updatedLobbyList) + _chatState.value = newState + } + } + + launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> + val currentChatLists = _chatState.value + val updatedTeamList = currentChatLists.teamMessages + msg + val newState = currentChatLists.copy(teamMessages = updatedTeamList) + _chatState.value = newState + } + } + + if (role == Role.OPERATIVE.name) { + launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username).collect { msg -> + val currentChatLists = _chatState.value + val updatedOperativeList = currentChatLists.operativeMessages + msg + val newState = currentChatLists.copy(operativeMessages = updatedOperativeList) + _chatState.value = newState + } + } + } + client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") From 2e5eb366d1e2ed5303a9e981fefd21988d6aebfb Mon Sep 17 00:00:00 2001 From: XtophB Date: Wed, 6 May 2026 01:57:18 +0200 Subject: [PATCH 012/121] feat: add send chat message functionality to view model --- .../frontend/viewmodel/GameViewModel.kt | 18 ++++++++++++++++++ 1 file changed, 18 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 1cd3608..8d5a45d 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -95,6 +95,24 @@ 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 handleMessage(message: GameMessage) { _uiState.value = message // Add logic to handle incoming messages From 95f634039cf2f3ee3766ea6044a0f3a041b88b8b Mon Sep 17 00:00:00 2001 From: XtophB Date: Wed, 6 May 2026 02:23:28 +0200 Subject: [PATCH 013/121] refactor: apply ktlintFormat for code style --- .../frontend/data/model/ChatDomainModel.kt | 6 +-- .../frontend/data/model/ChatLists.kt | 6 +-- .../data/model/enums/ChatMessageType.kt | 4 +- .../data/repository/ChatRepository.kt | 50 +++++++++++-------- .../frontend/network/dto/ChatMessageDto.kt | 5 +- .../network/websocket/GameWebSocketHandler.kt | 9 ++-- .../frontend/viewmodel/GameViewModel.kt | 27 +++++++--- 7 files changed, 64 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt index 1d916f8..39cfa8d 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt @@ -1,7 +1,7 @@ package com.codenames.frontend.data.model -data class ChatDomainModel ( +data class ChatDomainModel( val sender: String, val text: String, - val isFromMe: Boolean - ) \ No newline at end of file + val isFromMe: Boolean, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt index 938d72d..fbbae77 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt @@ -1,7 +1,7 @@ package com.codenames.frontend.data.model -data class ChatLists ( +data class ChatLists( val lobbyMessages: List = emptyList(), val teamMessages: List = emptyList(), - val operativeMessages: List = emptyList() -) \ No newline at end of file + val operativeMessages: List = emptyList(), +) 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 e406ce1..1ca50f5 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 @@ -4,5 +4,5 @@ import kotlinx.serialization.Serializable @Serializable enum class ChatMessageType { - CHAT -} \ No newline at end of file + CHAT, +} 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 39f3721..70959bc 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 @@ -5,29 +5,37 @@ import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.websocket.GameWebSocketHandler import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import javax.inject.Inject -class ChatRepository @Inject constructor( - private val webSocketHandler: GameWebSocketHandler -) { - fun observeChat(topic: String, currentUsername: String): Flow = - flow { - val subscriptionFlow = webSocketHandler.subscribeToChat(topic) +class ChatRepository + @Inject + constructor( + private val webSocketHandler: GameWebSocketHandler, + ) { + fun observeChat( + topic: String, + currentUsername: String, + ): Flow = + flow { + val subscriptionFlow = webSocketHandler.subscribeToChat(topic) - subscriptionFlow.collect { dto -> - emit( - ChatDomainModel( - sender = dto.senderUsername, - text = dto.content, - isFromMe = dto.senderUsername == currentUsername - ) - ) - } - } + subscriptionFlow.collect { dto -> + emit( + ChatDomainModel( + sender = dto.senderUsername, + text = dto.content, + isFromMe = dto.senderUsername == currentUsername, + ), + ) + } + } - suspend fun sendMessage(destination: String, username: String, text: String) { - val dto = ChatMessageDto(senderUsername = username, content = text) - webSocketHandler.sendChatMessage(destination, dto) + 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/network/dto/ChatMessageDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt index 099595c..4245e5d 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt @@ -3,10 +3,9 @@ package com.codenames.frontend.network.dto import com.codenames.frontend.data.model.enums.ChatMessageType import kotlinx.serialization.Serializable - @Serializable data class ChatMessageDto( val senderUsername: String, val content: String, - val type: ChatMessageType = ChatMessageType.CHAT -) \ No newline at end of file + val type: ChatMessageType = ChatMessageType.CHAT, +) 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 684e64d..4ba6227 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 @@ -39,11 +39,12 @@ class GameWebSocketHandler session.convertAndSend("app/$lobbyCode/join", msg, WebSocketJoinMessage.serializer()) } - suspend fun subscribeToChat(topicPath: String): Flow { - return session.subscribe(topicPath, ChatMessageDto.serializer()) - } + suspend fun subscribeToChat(topicPath: String): Flow = session.subscribe(topicPath, ChatMessageDto.serializer()) - suspend fun sendChatMessage(destination: String, msg: ChatMessageDto) { + suspend fun sendChatMessage( + destination: String, + msg: ChatMessageDto, + ) { session.convertAndSend(destination, msg, ChatMessageDto.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 8d5a45d..1f610d7 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -15,14 +15,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject -import kotlinx.coroutines.flow.update @HiltViewModel class GameViewModel @Inject constructor( private val client: GameWebSocketHandler, - private val chatRepository: ChatRepository + private val chatRepository: ChatRepository, ) : ViewModel() { private var job: Job? = null @@ -39,7 +38,7 @@ class GameViewModel username: String, lobbyCode: String, team: String, - role: String + role: String, ) { job?.cancel() @@ -95,24 +94,38 @@ class GameViewModel } } - fun sendLobbyMessage(lobbyCode: String, username: String, content: String) { + 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) { + 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) { + fun sendOperativeMessage( + lobbyCode: String, + team: String, + username: String, + content: String, + ) { viewModelScope.launch { chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) } } - + fun handleMessage(message: GameMessage) { _uiState.value = message // Add logic to handle incoming messages From b4d6ac0540ade1f8a2af8202f947b75b00ba0b6a Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 6 May 2026 16:09:52 +0200 Subject: [PATCH 014/121] added UserNameScreen --- .../frontend/ui/navigation/NavGraph.kt | 14 +++- .../frontend/ui/navigation/Screen.kt | 2 + .../frontend/ui/screens/GameSettingsScreen.kt | 5 +- .../frontend/ui/screens/JoinlobbyScreen.kt | 2 +- .../frontend/ui/screens/LobbyScreen.kt | 3 +- .../frontend/ui/screens/StartScreen.kt | 2 +- .../frontend/ui/screens/UserNameScreen.kt | 68 +++++++++++++++++++ 7 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt index 44b3f7a..73f96ff 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 @@ -21,6 +21,8 @@ import com.codenames.frontend.ui.screens.JoinlobbyScreen 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 + @Composable @Suppress("ktlint:standard:function-naming") @@ -29,9 +31,17 @@ fun NavGraph() { NavHost( navController = navController, - startDestination = Screen.Start.route, + startDestination = Screen.Username.route, ) { - composable(Screen.Start.route) { + composable(Screen.Username.route) { + UserNameScreen(navController) + } + composable( + route = "${Screen.Start.route}/{username}", + arguments = listOf(navArgument("username") { type = NavType.StringType }) + ) { backStackEntry -> + + val username = backStackEntry.arguments?.getString("username") ?: "Gast" StartScreen(navController) } diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt index fb7aa2d..05f3c39 100644 --- a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt @@ -14,4 +14,6 @@ sealed class Screen( object GameSettings : Screen("game_settings") object Settings : Screen("settings") + + object Username : Screen("username") } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt index d01861c..990460b 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -8,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Suppress("ktlint:standard:function-naming") @@ -17,7 +19,8 @@ fun GameSettingsScreen() { modifier = Modifier .fillMaxSize() - .padding(16.dp), + .padding(16.dp) + .background(Color(0xFFf0d8ce)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt index ef7a707..1cb6f88 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 @@ -81,7 +81,7 @@ fun JoinlobbyScreen() { modifier = Modifier .fillMaxSize() - .background(Color(0xFF4A403D)) + .background(Color(0xFFf0d8ce)) .padding(24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, 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 24553ea..ee249a2 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,8 @@ fun LobbyScreen(navController: NavHostController) { modifier = Modifier .fillMaxSize() - .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + .background(Color(0xFFf0d8ce)), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt index ff37092..d2c19c4 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 @@ -58,7 +58,7 @@ fun StartScreen( modifier = Modifier .fillMaxSize() - .background(Color(0xFF4A403D)), + .background(Color(0xFFf0d8ce)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt new file mode 100644 index 0000000..9e633d0 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -0,0 +1,68 @@ +package com.codenames.frontend.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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 androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import com.codenames.frontend.ui.buttons.AppButton +import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.navigation.Screen + +@Composable +fun UserNameScreen(navController: NavController) { + + var username by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFf0d8ce)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) + + { + Text( + text = "Codenames", + fontSize = 48.sp, + modifier = Modifier.padding(bottom = 100.dp) + ) + + TextField( + value = username, + onValueChange = { username = it }, + label = { Text("enter username") }, + modifier = Modifier.fillMaxWidth(0.5f) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + + AppButton( + text = "Continue", + onClick = { navController.navigate("${Screen.Start.route}/$username") }, + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = username.isNotBlank(), + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + } +} + + + From d3fc77bed6fa77cf598b767fc751c971dc74c560 Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 6 May 2026 16:23:55 +0200 Subject: [PATCH 015/121] fixed formatting issues --- .../frontend/ui/navigation/NavGraph.kt | 47 +++--- .../frontend/ui/screens/GameSettingsScreen.kt | 9 +- .../frontend/ui/screens/GameboardScreen.kt | 3 +- .../frontend/ui/screens/JoinlobbyScreen.kt | 100 +++++------ .../frontend/ui/screens/LobbyScreen.kt | 159 ++++++++---------- .../frontend/ui/screens/SettingsScreen.kt | 7 +- .../frontend/ui/screens/StartScreen.kt | 8 +- .../frontend/ui/screens/UserNameScreen.kt | 26 ++- 8 files changed, 162 insertions(+), 197 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 73f96ff..a1c1416 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 @@ -23,7 +23,6 @@ import com.codenames.frontend.ui.screens.SettingsScreen import com.codenames.frontend.ui.screens.StartScreen import com.codenames.frontend.ui.screens.UserNameScreen - @Composable @Suppress("ktlint:standard:function-naming") fun NavGraph() { @@ -59,15 +58,13 @@ fun NavGraph() { arguments = listOf(navArgument("role") { type = NavType.StringType }), ) { backStackEntry -> - val roleString = - backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name + val roleString = backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name - val passedRole = - try { - PlayerRoles.valueOf(roleString) - } catch (e: IllegalArgumentException) { - PlayerRoles.NONE - } + val passedRole = try { + PlayerRoles.valueOf(roleString) + } catch (e: IllegalArgumentException) { + PlayerRoles.NONE + } GameScreenWrapper(userRole = passedRole) } @@ -91,23 +88,21 @@ fun NavGraph() { fun GameScreenWrapper(userRole: PlayerRoles) { var currentHint by remember { mutableStateOf("Waiting for hint...") } - val cards = - remember { - mutableStateListOf( - *List(25) { - GameCard( - word = "Word ${it + 1}", - type = - when (it) { - 0 -> CardType.ASSASSIN - in 1..8 -> CardType.BLUE - in 9..15 -> CardType.RED - else -> CardType.NEUTRAL - }, - ) - }.toTypedArray(), - ) - } + val cards = remember { + mutableStateListOf( + *List(25) { + GameCard( + word = "Word ${it + 1}", + type = when (it) { + 0 -> CardType.ASSASSIN + in 1..8 -> CardType.BLUE + in 9..15 -> CardType.RED + else -> CardType.NEUTRAL + }, + ) + }.toTypedArray(), + ) + } fun revealCard(index: Int) { val card = cards[index] 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..d3b07e2 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 @@ -16,11 +16,10 @@ import androidx.compose.ui.unit.dp @Composable fun GameSettingsScreen() { Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp) - .background(Color(0xFFf0d8ce)), + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .background(Color(0xFFf0d8ce)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt index 0ac3e59..d77cced 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 @@ -122,7 +122,8 @@ fun GameboardScreen( modifier: Modifier = Modifier, ) { var hintInput by rememberSaveable { mutableStateOf("") } - val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER + val isSpymaster = + userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val blueLeft = cards.count { it.type == CardType.BLUE && !it.revealed } 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 1cb6f88..6a50473 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 @@ -40,10 +40,7 @@ internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" private const val LOBBY_ID_MAX_LENGTH = 12 internal fun sanitizeLobbyIdInput(input: String): String = - input - .uppercase() - .filter { it.isLetterOrDigit() } - .take(LOBBY_ID_MAX_LENGTH) + input.uppercase().filter { it.isLetterOrDigit() }.take(LOBBY_ID_MAX_LENGTH) internal fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() @@ -57,14 +54,12 @@ fun JoinlobbyScreen() { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) + val blueGradient = Brush.verticalGradient( + colors = listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), + ) val joinEnabled = isLobbyIdValid(lobbyId) @@ -78,11 +73,10 @@ fun JoinlobbyScreen() { } Column( - modifier = - Modifier - .fillMaxSize() - .background(Color(0xFFf0d8ce)) - .padding(24.dp), + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFf0d8ce)) + .padding(24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -91,53 +85,45 @@ fun JoinlobbyScreen() { onValueChange = { newValue -> lobbyId = sanitizeLobbyIdInput(newValue) }, - modifier = - Modifier - .fillMaxWidth(0.5f) - .padding(bottom = 16.dp) - .testTag(JOIN_LOBBY_INPUT_TAG), - state = - AppTextFieldState( - label = "Lobby ID", - placeholder = "Enter Lobby ID", + modifier = Modifier + .fillMaxWidth(0.5f) + .padding(bottom = 16.dp) + .testTag(JOIN_LOBBY_INPUT_TAG), + state = AppTextFieldState( + label = "Lobby ID", + placeholder = "Enter Lobby ID", + ), + style = AppTextFieldStyle( + type = AppTextFieldType.SECONDARY, + contentColor = Color.White, + fontSize = 20.sp, + lineHeight = 24.sp, + ), + keyboard = AppTextFieldKeyboard( + options = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, ), - style = - AppTextFieldStyle( - type = AppTextFieldType.SECONDARY, - contentColor = Color.White, - fontSize = 20.sp, - lineHeight = 24.sp, - ), - keyboard = - AppTextFieldKeyboard( - options = - KeyboardOptions( - capitalization = KeyboardCapitalization.Characters, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Done, - ), - actions = - KeyboardActions( - onDone = { submitJoin() }, - ), + actions = KeyboardActions( + onDone = { submitJoin() }, ), + ), ) AppButton( text = "Join Lobby", onClick = { submitJoin() }, - modifier = - Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = - AppButtonStyle( - enabled = joinEnabled, - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), + modifier = Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = AppButtonStyle( + enabled = joinEnabled, + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt index ee249a2..9b3d00e 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 @@ -31,41 +31,33 @@ import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles -val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) +val blueGradient = Brush.verticalGradient( + colors = listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), +) -val redGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFFCF5530), - Color(0xFFDE8468), - ), - ) +val redGradient = Brush.verticalGradient( + colors = listOf( + Color(0xFFCF5530), + Color(0xFFDE8468), + ), +) -val brownGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF383330), - Color(0xFF1A1513), - ), - ) +val brownGradient = Brush.verticalGradient( + colors = listOf( + Color(0xFF383330), + Color(0xFF1A1513), + ), +) -val greenGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), - ) +val greenGradient = Brush.verticalGradient( + colors = listOf( + Color(0xFF4CAF50), + Color(0xFF2E7D32), + ), +) private const val JOIN_TEAM: String = "JOIN TEAM" private const val TEAM_JOINED: String = "👤 1 joined" @@ -76,11 +68,10 @@ fun LobbyScreen(navController: NavHostController) { var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } Row( - modifier = - Modifier - .fillMaxSize() - .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) - .background(Color(0xFFf0d8ce)), + modifier = Modifier + .fillMaxSize() + .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + .background(Color(0xFFf0d8ce)), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -128,27 +119,25 @@ fun TeamColumn( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - val cardModifier = - Modifier - .align(align) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(gradient, RoundedCornerShape(12.dp)) - .padding(12.dp) + val cardModifier = Modifier + .align(align) + .width(200.dp) + .height(150.dp) + .fillMaxWidth(0.5f) + .padding(start = 6.dp, bottom = 12.dp) + .background(gradient, RoundedCornerShape(12.dp)) + .padding(12.dp) Text( text = title, color = textColor, fontSize = 24.sp, fontWeight = FontWeight.Bold, - modifier = - Modifier - .align(align) - .padding(start = 6.dp) - .padding(end = 6.dp) - .padding(bottom = 6.dp), + modifier = Modifier + .align(align) + .padding(start = 6.dp) + .padding(end = 6.dp) + .padding(bottom = 6.dp), ) RoleCard( @@ -196,11 +185,10 @@ fun RoleCard( AppButton( text = JOIN_TEAM, onClick = { onRoleSelect(role) }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), + style = AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 16.sp, + ), ) } } @@ -217,14 +205,13 @@ fun GameSettingsColumn( horizontalAlignment = Alignment.CenterHorizontally, ) { Column( - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(250.dp) - .fillMaxWidth(0.5f) - .background(brownGradient, RoundedCornerShape(12.dp)) - .padding(16.dp), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(400.dp) + .height(250.dp) + .fillMaxWidth(0.5f) + .background(brownGradient, RoundedCornerShape(12.dp)) + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { @@ -238,16 +225,14 @@ fun GameSettingsColumn( AppButton( text = "TIMER: OFF", onClick = { /* TODO: Timer Logik */ }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - style = - AppButtonStyle( - containerColor = Color(0xFF555555), - contentColor = Color.White, - fontSize = 18.sp, - ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = AppButtonStyle( + containerColor = Color(0xFF555555), + contentColor = Color.White, + fontSize = 18.sp, + ), ) } @@ -256,19 +241,17 @@ fun GameSettingsColumn( onClick = { navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") }, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(70.dp) - .fillMaxWidth(0.5f) - .padding(top = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 28.sp, - lineHeight = 30.sp, - ), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(400.dp) + .height(70.dp) + .fillMaxWidth(0.5f) + .padding(top = 12.dp), + style = AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 28.sp, + lineHeight = 30.sp, + ), ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt index 4db6df1..73f36a9 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 @@ -14,10 +14,9 @@ import androidx.compose.ui.unit.dp @Composable fun SettingsScreen() { Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { 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 d2c19c4..3b0af57 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 @@ -144,12 +144,18 @@ fun StartScreen( ) when (state) { - is ConnectionState.CONNECTING -> Text(text = "Connecting...", color = Color.Yellow, fontSize = 25.sp) + is ConnectionState.CONNECTING -> Text( + text = "Connecting...", + color = Color.Yellow, + fontSize = 25.sp + ) + is ConnectionState.CONNECTED -> Text("Connected", color = Color.Green, fontSize = 25.sp) is ConnectionState.Error -> { Text("Error while connecting: ") Text((state as ConnectionState.Error).message) } + else -> {} } } 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 9e633d0..f41c087 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 @@ -30,9 +30,7 @@ fun UserNameScreen(navController: NavController) { { Text( - text = "Codenames", - fontSize = 48.sp, - modifier = Modifier.padding(bottom = 100.dp) + text = "Codenames", fontSize = 48.sp, modifier = Modifier.padding(bottom = 100.dp) ) TextField( @@ -48,18 +46,16 @@ fun UserNameScreen(navController: NavController) { AppButton( text = "Continue", onClick = { navController.navigate("${Screen.Start.route}/$username") }, - modifier = - Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = - AppButtonStyle( - enabled = username.isNotBlank(), - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), + modifier = Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = AppButtonStyle( + enabled = username.isNotBlank(), + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), ) } } From a4d5d5b975fe98d5af79e262e31681aa5a9782d4 Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 6 May 2026 16:40:25 +0200 Subject: [PATCH 016/121] fixed more formatting issues --- .../frontend/ui/navigation/NavGraph.kt | 45 ++--- .../frontend/ui/screens/GameSettingsScreen.kt | 9 +- .../frontend/ui/screens/JoinlobbyScreen.kt | 98 ++++++----- .../frontend/ui/screens/LobbyScreen.kt | 159 ++++++++++-------- .../frontend/ui/screens/SettingsScreen.kt | 7 +- .../frontend/ui/screens/StartScreen.kt | 11 +- .../frontend/ui/screens/UserNameScreen.kt | 67 +++++--- 7 files changed, 220 insertions(+), 176 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 a1c1416..59158d7 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 @@ -37,7 +37,7 @@ fun NavGraph() { } composable( route = "${Screen.Start.route}/{username}", - arguments = listOf(navArgument("username") { type = NavType.StringType }) + arguments = listOf(navArgument("username") { type = NavType.StringType }), ) { backStackEntry -> val username = backStackEntry.arguments?.getString("username") ?: "Gast" @@ -60,11 +60,12 @@ fun NavGraph() { val roleString = backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name - val passedRole = try { - PlayerRoles.valueOf(roleString) - } catch (e: IllegalArgumentException) { - PlayerRoles.NONE - } + val passedRole = + try { + PlayerRoles.valueOf(roleString) + } catch (e: IllegalArgumentException) { + PlayerRoles.NONE + } GameScreenWrapper(userRole = passedRole) } @@ -88,21 +89,23 @@ fun NavGraph() { fun GameScreenWrapper(userRole: PlayerRoles) { var currentHint by remember { mutableStateOf("Waiting for hint...") } - val cards = remember { - mutableStateListOf( - *List(25) { - GameCard( - word = "Word ${it + 1}", - type = when (it) { - 0 -> CardType.ASSASSIN - in 1..8 -> CardType.BLUE - in 9..15 -> CardType.RED - else -> CardType.NEUTRAL - }, - ) - }.toTypedArray(), - ) - } + val cards = + remember { + mutableStateListOf( + *List(25) { + GameCard( + word = "Word ${it + 1}", + type = + when (it) { + 0 -> CardType.ASSASSIN + in 1..8 -> CardType.BLUE + in 9..15 -> CardType.RED + else -> CardType.NEUTRAL + }, + ) + }.toTypedArray(), + ) + } fun revealCard(index: Int) { val card = cards[index] 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 d3b07e2..990460b 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt @@ -16,10 +16,11 @@ import androidx.compose.ui.unit.dp @Composable fun GameSettingsScreen() { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .background(Color(0xFFf0d8ce)), + modifier = + Modifier + .fillMaxSize() + .padding(16.dp) + .background(Color(0xFFf0d8ce)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { 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 6a50473..c3fe4fe 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,8 +39,7 @@ internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" private const val LOBBY_ID_MAX_LENGTH = 12 -internal fun sanitizeLobbyIdInput(input: String): String = - input.uppercase().filter { it.isLetterOrDigit() }.take(LOBBY_ID_MAX_LENGTH) +internal fun sanitizeLobbyIdInput(input: String): String = input.uppercase().filter { it.isLetterOrDigit() }.take(LOBBY_ID_MAX_LENGTH) internal fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() @@ -54,12 +53,14 @@ fun JoinlobbyScreen() { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val blueGradient = Brush.verticalGradient( - colors = listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) + val blueGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), + ) val joinEnabled = isLobbyIdValid(lobbyId) @@ -73,10 +74,11 @@ fun JoinlobbyScreen() { } Column( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFFf0d8ce)) - .padding(24.dp), + modifier = + Modifier + .fillMaxSize() + .background(Color(0xFFf0d8ce)) + .padding(24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -85,45 +87,53 @@ fun JoinlobbyScreen() { onValueChange = { newValue -> lobbyId = sanitizeLobbyIdInput(newValue) }, - modifier = Modifier - .fillMaxWidth(0.5f) - .padding(bottom = 16.dp) - .testTag(JOIN_LOBBY_INPUT_TAG), - state = AppTextFieldState( - label = "Lobby ID", - placeholder = "Enter Lobby ID", - ), - style = AppTextFieldStyle( - type = AppTextFieldType.SECONDARY, - contentColor = Color.White, - fontSize = 20.sp, - lineHeight = 24.sp, - ), - keyboard = AppTextFieldKeyboard( - options = KeyboardOptions( - capitalization = KeyboardCapitalization.Characters, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Done, + modifier = + Modifier + .fillMaxWidth(0.5f) + .padding(bottom = 16.dp) + .testTag(JOIN_LOBBY_INPUT_TAG), + state = + AppTextFieldState( + label = "Lobby ID", + placeholder = "Enter Lobby ID", ), - actions = KeyboardActions( - onDone = { submitJoin() }, + style = + AppTextFieldStyle( + type = AppTextFieldType.SECONDARY, + contentColor = Color.White, + fontSize = 20.sp, + lineHeight = 24.sp, + ), + keyboard = + AppTextFieldKeyboard( + options = + KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + ), + actions = + KeyboardActions( + onDone = { submitJoin() }, + ), ), - ), ) AppButton( text = "Join Lobby", onClick = { submitJoin() }, - modifier = Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = AppButtonStyle( - enabled = joinEnabled, - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = joinEnabled, + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt index 9b3d00e..ee249a2 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 @@ -31,33 +31,41 @@ import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles -val blueGradient = Brush.verticalGradient( - colors = listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), -) +val blueGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), + ) -val redGradient = Brush.verticalGradient( - colors = listOf( - Color(0xFFCF5530), - Color(0xFFDE8468), - ), -) +val redGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFCF5530), + Color(0xFFDE8468), + ), + ) -val brownGradient = Brush.verticalGradient( - colors = listOf( - Color(0xFF383330), - Color(0xFF1A1513), - ), -) +val brownGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF383330), + Color(0xFF1A1513), + ), + ) -val greenGradient = Brush.verticalGradient( - colors = listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), -) +val greenGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF4CAF50), + Color(0xFF2E7D32), + ), + ) private const val JOIN_TEAM: String = "JOIN TEAM" private const val TEAM_JOINED: String = "👤 1 joined" @@ -68,10 +76,11 @@ fun LobbyScreen(navController: NavHostController) { var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } Row( - modifier = Modifier - .fillMaxSize() - .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) - .background(Color(0xFFf0d8ce)), + modifier = + Modifier + .fillMaxSize() + .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + .background(Color(0xFFf0d8ce)), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -119,25 +128,27 @@ fun TeamColumn( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - val cardModifier = Modifier - .align(align) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(gradient, RoundedCornerShape(12.dp)) - .padding(12.dp) + val cardModifier = + Modifier + .align(align) + .width(200.dp) + .height(150.dp) + .fillMaxWidth(0.5f) + .padding(start = 6.dp, bottom = 12.dp) + .background(gradient, RoundedCornerShape(12.dp)) + .padding(12.dp) Text( text = title, color = textColor, fontSize = 24.sp, fontWeight = FontWeight.Bold, - modifier = Modifier - .align(align) - .padding(start = 6.dp) - .padding(end = 6.dp) - .padding(bottom = 6.dp), + modifier = + Modifier + .align(align) + .padding(start = 6.dp) + .padding(end = 6.dp) + .padding(bottom = 6.dp), ) RoleCard( @@ -185,10 +196,11 @@ fun RoleCard( AppButton( text = JOIN_TEAM, onClick = { onRoleSelect(role) }, - style = AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 16.sp, + ), ) } } @@ -205,13 +217,14 @@ fun GameSettingsColumn( horizontalAlignment = Alignment.CenterHorizontally, ) { Column( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(250.dp) - .fillMaxWidth(0.5f) - .background(brownGradient, RoundedCornerShape(12.dp)) - .padding(16.dp), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(400.dp) + .height(250.dp) + .fillMaxWidth(0.5f) + .background(brownGradient, RoundedCornerShape(12.dp)) + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { @@ -225,14 +238,16 @@ fun GameSettingsColumn( AppButton( text = "TIMER: OFF", onClick = { /* TODO: Timer Logik */ }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - style = AppButtonStyle( - containerColor = Color(0xFF555555), - contentColor = Color.White, - fontSize = 18.sp, - ), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = + AppButtonStyle( + containerColor = Color(0xFF555555), + contentColor = Color.White, + fontSize = 18.sp, + ), ) } @@ -241,17 +256,19 @@ fun GameSettingsColumn( onClick = { navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") }, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(70.dp) - .fillMaxWidth(0.5f) - .padding(top = 12.dp), - style = AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 28.sp, - lineHeight = 30.sp, - ), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(400.dp) + .height(70.dp) + .fillMaxWidth(0.5f) + .padding(top = 12.dp), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 28.sp, + lineHeight = 30.sp, + ), ) } } 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 73f36a9..4db6df1 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 @@ -14,9 +14,10 @@ import androidx.compose.ui.unit.dp @Composable fun SettingsScreen() { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { 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 3b0af57..7a753ee 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 @@ -144,11 +144,12 @@ fun StartScreen( ) when (state) { - is ConnectionState.CONNECTING -> Text( - text = "Connecting...", - color = Color.Yellow, - fontSize = 25.sp - ) + is ConnectionState.CONNECTING -> + Text( + text = "Connecting...", + color = Color.Yellow, + fontSize = 25.sp, + ) is ConnectionState.CONNECTED -> Text("Connected", color = Color.Green, fontSize = 25.sp) is ConnectionState.Error -> { 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 f41c087..25ca258 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 @@ -1,64 +1,75 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.navigation.Screen +@Suppress("ktlint:standard:function-naming") @Composable fun UserNameScreen(navController: NavController) { - var username by remember { mutableStateOf("") } Column( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFFf0d8ce)), + modifier = + Modifier + .fillMaxSize() + .background(Color(0xFFf0d8ce)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, - ) - - { + ) { Text( - text = "Codenames", fontSize = 48.sp, modifier = Modifier.padding(bottom = 100.dp) + text = "Codenames", + fontSize = 48.sp, + modifier = Modifier.padding(bottom = 100.dp), ) TextField( value = username, onValueChange = { username = it }, label = { Text("enter username") }, - modifier = Modifier.fillMaxWidth(0.5f) + modifier = Modifier.fillMaxWidth(0.5f), ) Spacer(modifier = Modifier.height(10.dp)) - AppButton( text = "Continue", onClick = { navController.navigate("${Screen.Start.route}/$username") }, - modifier = Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = AppButtonStyle( - enabled = username.isNotBlank(), - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = username.isNotBlank(), + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), ) } } - - - From ac7cc46a5f5a78764a3fad733150e42065e6f932 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Wed, 6 May 2026 18:04:46 +0200 Subject: [PATCH 017/121] Settings Screen implemented --- ....sync-conflict-20260504-182215-U4KWF3D.xml | 9 ++ .idea/misc.xml | 1 - .../ui/buttons/SettingsCornerButton.kt | 67 ++++++++ .../frontend/ui/navigation/NavGraph.kt | 38 +++-- .../frontend/ui/screens/GameboardScreen.kt | 110 +++++++------ .../frontend/ui/screens/JoinlobbyScreen.kt | 130 +++++++++------- .../frontend/ui/screens/LobbyScreen.kt | 62 +++++--- .../frontend/ui/screens/SettingsScreen.kt | 70 ++++++++- .../frontend/ui/screens/StartScreen.kt | 144 ++++++++++-------- .../frontend/ui/screens/UserNameScreen.kt | 70 +++++---- 10 files changed, 457 insertions(+), 244 deletions(-) create mode 100644 .idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml create mode 100644 app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt diff --git a/.idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml b/.idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml new file mode 100644 index 0000000..6c5519f --- /dev/null +++ b/.idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 3b0be22..6c5519f 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt new file mode 100644 index 0000000..187d30e --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt @@ -0,0 +1,67 @@ +package com.codenames.frontend.ui.buttons + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex + +private val cornerButtonColor = Color(0xFF383330) + +@Suppress("ktlint:standard:function-naming") +@Composable +fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp) + .width(140.dp) + .height(56.dp) + .zIndex(1f), + ) { + AppButton( + text = "Settings", + onClick = onClick, + modifier = Modifier.fillMaxSize(), + style = cornerButtonStyle(), + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun BoxScope.ReturnCornerButton(onClick: () -> Unit) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp) + .width(140.dp) + .height(56.dp) + .zIndex(1f), + ) { + AppButton( + text = "Return", + onClick = onClick, + modifier = Modifier.fillMaxSize(), + style = cornerButtonStyle(), + ) + } +} + +private fun cornerButtonStyle(): AppButtonStyle = + AppButtonStyle( + containerColor = cornerButtonColor, + contentColor = Color.White, + fontSize = 16.sp, + lineHeight = 18.sp, + ) diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt index 59158d7..91f9c27 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -35,12 +36,14 @@ fun NavGraph() { composable(Screen.Username.route) { UserNameScreen(navController) } + composable( route = "${Screen.Start.route}/{username}", - arguments = listOf(navArgument("username") { type = NavType.StringType }), - ) { backStackEntry -> - - val username = backStackEntry.arguments?.getString("username") ?: "Gast" + arguments = + listOf( + navArgument("username") { type = NavType.StringType }, + ), + ) { StartScreen(navController) } @@ -49,16 +52,18 @@ fun NavGraph() { } composable(Screen.JoinLobby.route) { - JoinlobbyScreen() + JoinlobbyScreen(navController) } - // ---------------- GAME SCREEN ---------------- composable( route = "${Screen.Gameboard.route}/{role}", - arguments = listOf(navArgument("role") { type = NavType.StringType }), + arguments = + listOf( + navArgument("role") { type = NavType.StringType }, + ), ) { backStackEntry -> - - val roleString = backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name + val roleString = + backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name val passedRole = try { @@ -67,7 +72,10 @@ fun NavGraph() { PlayerRoles.NONE } - GameScreenWrapper(userRole = passedRole) + GameScreenWrapper( + navController = navController, + userRole = passedRole, + ) } composable(Screen.GameSettings.route) { @@ -75,7 +83,7 @@ fun NavGraph() { } composable(Screen.Settings.route) { - SettingsScreen() + SettingsScreen(navController) } composable("game_test") { @@ -86,7 +94,10 @@ fun NavGraph() { @Composable @Suppress("ktlint:standard:function-naming") -fun GameScreenWrapper(userRole: PlayerRoles) { +fun GameScreenWrapper( + navController: NavHostController, + userRole: PlayerRoles, +) { var currentHint by remember { mutableStateOf("Waiting for hint...") } val cards = @@ -120,5 +131,8 @@ fun GameScreenWrapper(userRole: PlayerRoles) { onReveal = { index -> revealCard(index) }, + onSettingsClick = { + navController.navigate(Screen.Settings.route) + }, ) } 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 d77cced..42af587 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.composables.GameBoardGrid import com.codenames.frontend.ui.inputs.AppTextField import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard @@ -120,6 +121,7 @@ fun GameboardScreen( cards: List, onReveal: (Int) -> Unit, modifier: Modifier = Modifier, + onSettingsClick: (() -> Unit)? = null, ) { var hintInput by rememberSaveable { mutableStateOf("") } val isSpymaster = @@ -134,66 +136,74 @@ fun GameboardScreen( val onInputChange: (String) -> Unit = { hintInput = it } - Column( - modifier = - modifier - .fillMaxHeight() - .padding(16.dp), - ) { - Row( + Box(modifier = modifier.fillMaxSize()) { + Column( modifier = Modifier - .weight(1f) - .fillMaxWidth(), + .fillMaxSize() + .padding(top = 72.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), ) { - TeamSidebar( - userRole, - color = Team.BLUE, - teamLeft = blueLeft, - textColor = Color(0xFF1565C0), - gradient = blueGradient, - ) - - GameBoardGrid( - cards, - scale, - offset, - isSpymaster, - onReveal, + Row( modifier = Modifier .weight(1f) - .fillMaxHeight() - .padding(horizontal = 8.dp) - .clipToBounds() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(0.5f, 3f) - offset += pan - } - }, - ) + .fillMaxWidth(), + ) { + TeamSidebar( + userRole, + color = Team.BLUE, + teamLeft = blueLeft, + textColor = Color(0xFF1565C0), + gradient = blueGradient, + ) - TeamSidebar( - userRole, - color = Team.RED, - teamLeft = redLeft, - textColor = Color(0xFFCF5530), - gradient = redGradient, + GameBoardGrid( + cards, + scale, + offset, + isSpymaster, + onReveal, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .clipToBounds() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + offset += pan + } + }, + ) + + TeamSidebar( + userRole, + color = Team.RED, + teamLeft = redLeft, + textColor = Color(0xFFCF5530), + gradient = redGradient, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + HintSection( + isSpymaster, + currentHint, + hintInput, + onHintChange, + onInputChange, + keyboardController, + focusManager, ) } - Spacer(modifier = Modifier.height(12.dp)) - - HintSection( - isSpymaster, - currentHint, - hintInput, - onHintChange, - onInputChange, - keyboardController, - focusManager, - ) + onSettingsClick?.let { openSettings -> + SettingsCornerButton( + onClick = openSettings, + ) + } } } 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 c3fe4fe..5766599 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 @@ -2,6 +2,7 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -27,25 +28,32 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.inputs.AppTextField import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard import com.codenames.frontend.ui.inputs.AppTextFieldState import com.codenames.frontend.ui.inputs.AppTextFieldStyle import com.codenames.frontend.ui.inputs.AppTextFieldType +import com.codenames.frontend.ui.navigation.Screen internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" private const val LOBBY_ID_MAX_LENGTH = 12 -internal fun sanitizeLobbyIdInput(input: String): String = input.uppercase().filter { it.isLetterOrDigit() }.take(LOBBY_ID_MAX_LENGTH) +internal fun sanitizeLobbyIdInput(input: String): String = + input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LOBBY_ID_MAX_LENGTH) internal fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() @Suppress("ktlint:standard:function-naming") @Composable -fun JoinlobbyScreen() { +fun JoinlobbyScreen(navController: NavHostController) { ForceLandscape() var lobbyId by rememberSaveable { mutableStateOf("") } @@ -73,67 +81,77 @@ fun JoinlobbyScreen() { // Hier später den echten Frontend-Join-Flow anschließen. } - Column( + Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFFf0d8ce)) - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .background(Color(0xFFf0d8ce)), ) { - AppTextField( - value = lobbyId, - onValueChange = { newValue -> - lobbyId = sanitizeLobbyIdInput(newValue) - }, + Column( modifier = Modifier - .fillMaxWidth(0.5f) - .padding(bottom = 16.dp) - .testTag(JOIN_LOBBY_INPUT_TAG), - state = - AppTextFieldState( - label = "Lobby ID", - placeholder = "Enter Lobby ID", - ), - style = - AppTextFieldStyle( - type = AppTextFieldType.SECONDARY, - contentColor = Color.White, - fontSize = 20.sp, - lineHeight = 24.sp, - ), - keyboard = - AppTextFieldKeyboard( - options = - KeyboardOptions( - capitalization = KeyboardCapitalization.Characters, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Done, - ), - actions = - KeyboardActions( - onDone = { submitJoin() }, - ), - ), - ) + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppTextField( + value = lobbyId, + onValueChange = { newValue -> + lobbyId = sanitizeLobbyIdInput(newValue) + }, + modifier = + Modifier + .fillMaxWidth(0.5f) + .padding(bottom = 16.dp) + .testTag(JOIN_LOBBY_INPUT_TAG), + state = + AppTextFieldState( + label = "Lobby ID", + placeholder = "Enter Lobby ID", + ), + style = + AppTextFieldStyle( + type = AppTextFieldType.SECONDARY, + contentColor = Color.White, + fontSize = 20.sp, + lineHeight = 24.sp, + ), + keyboard = + AppTextFieldKeyboard( + options = + KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + ), + actions = + KeyboardActions( + onDone = { submitJoin() }, + ), + ), + ) - AppButton( - text = "Join Lobby", - onClick = { submitJoin() }, - modifier = - Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = - AppButtonStyle( - enabled = joinEnabled, - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), + AppButton( + text = "Join Lobby", + onClick = { submitJoin() }, + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = joinEnabled, + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + } + + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, ) } } 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 ee249a2..313d6de 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 @@ -2,6 +2,7 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -28,6 +29,7 @@ import androidx.navigation.NavHostController import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles @@ -75,38 +77,48 @@ private const val TEAM_JOINED: String = "👤 1 joined" fun LobbyScreen(navController: NavHostController) { var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } - Row( + Box( modifier = Modifier .fillMaxSize() - .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) .background(Color(0xFFf0d8ce)), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, ) { - TeamColumn( - modifier = Modifier.weight(1f), - color = Team.BLUE, - gradient = blueGradient, - textColor = Color(0xFF42A5F5), - title = "BLUE TEAM", - currentRole = currentRole, - onRoleSelect = { currentRole = it }, - ) + Row( + modifier = + Modifier + .fillMaxSize() + .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + TeamColumn( + modifier = Modifier.weight(1f), + color = Team.BLUE, + gradient = blueGradient, + textColor = Color(0xFF42A5F5), + title = "BLUE TEAM", + currentRole = currentRole, + onRoleSelect = { currentRole = it }, + ) - GameSettingsColumn( - currentRole = currentRole, - navController = navController, - ) + GameSettingsColumn( + currentRole = currentRole, + navController = navController, + ) - TeamColumn( - modifier = Modifier.weight(1f), - color = Team.RED, - gradient = redGradient, - textColor = Color(0xFFDE8468), - title = "RED TEAM", - currentRole = currentRole, - onRoleSelect = { currentRole = it }, + TeamColumn( + modifier = Modifier.weight(1f), + color = Team.RED, + gradient = redGradient, + textColor = Color(0xFFDE8468), + title = "RED TEAM", + currentRole = currentRole, + onRoleSelect = { currentRole = it }, + ) + } + + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt index 4db6df1..87ea87b 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt @@ -1,26 +1,84 @@ package com.codenames.frontend.ui.screens +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import com.codenames.frontend.ui.buttons.AppButton +import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.ReturnCornerButton @Suppress("ktlint:standard:function-naming") @Composable -fun SettingsScreen() { - Column( +fun SettingsScreen(navController: NavHostController) { + Box( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .background(Color(0xFFf0d8ce)), ) { - Text("Insert Game Settings here") + Text( + text = "SETTINGS", + color = Color(0xFF383330), + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp), + ) + + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppButton( + text = "Toggle SFX", + onClick = {}, + modifier = + Modifier + .width(240.dp) + .height(80.dp), + style = + AppButtonStyle( + backgroundBrush = blueGradient, + fontSize = 24.sp, + lineHeight = 28.sp, + ), + ) + + AppButton( + text = "Toggle Music", + onClick = {}, + modifier = + Modifier + .width(240.dp) + .height(80.dp), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 24.sp, + lineHeight = 28.sp, + ), + ) + } + + ReturnCornerButton( + onClick = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt index 7a753ee..ca34902 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt @@ -2,6 +2,7 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +25,7 @@ import androidx.navigation.NavHostController import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.viewmodel.GameViewModel @@ -54,31 +56,72 @@ fun StartScreen( ), ) - Column( + Box( modifier = Modifier .fillMaxSize() .background(Color(0xFFf0d8ce)), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = - Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { + Row( + modifier = + Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + AppButton( + text = "Create Lobby", + onClick = { + navController.navigate(Screen.Lobby.route) + }, + modifier = + Modifier + .width(200.dp) + .height(100.dp) + .fillMaxWidth(0.5f) + .padding(bottom = 12.dp, end = 12.dp), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + + AppButton( + text = "Join Lobby", + onClick = { + navController.navigate(Screen.JoinLobby.route) + }, + modifier = + Modifier + .width(200.dp) + .height(100.dp) + .fillMaxWidth(0.5f) + .padding(bottom = 12.dp, start = 12.dp), + style = + AppButtonStyle( + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + } + AppButton( - text = "Create Lobby", + text = "test Mode", onClick = { - navController.navigate(Screen.Lobby.route) + navController.navigate("game_test") }, modifier = Modifier .width(200.dp) .height(100.dp) - .fillMaxWidth(0.5f) - .padding(bottom = 12.dp, end = 12.dp), + .padding(bottom = 12.dp), style = AppButtonStyle( backgroundBrush = greenGradient, @@ -88,16 +131,15 @@ fun StartScreen( ) AppButton( - text = "Join Lobby", + text = "Connect to Server", onClick = { - navController.navigate(Screen.JoinLobby.route) + viewModel.connect("TestUser", "12345") }, modifier = Modifier .width(200.dp) .height(100.dp) - .fillMaxWidth(0.5f) - .padding(bottom = 12.dp, start = 12.dp), + .padding(bottom = 12.dp), style = AppButtonStyle( backgroundBrush = blueGradient, @@ -105,59 +147,33 @@ fun StartScreen( lineHeight = 30.sp, ), ) - } - AppButton( - text = "test Mode", - onClick = { - navController.navigate("game_test") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) + when (state) { + is ConnectionState.CONNECTING -> + Text( + text = "Connecting...", + color = Color.Yellow, + fontSize = 25.sp, + ) - AppButton( - text = "Connect to Server", - onClick = { - viewModel.connect("TestUser", "12345") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) + is ConnectionState.CONNECTED -> + Text( + text = "Connected", + color = Color.Green, + fontSize = 25.sp, + ) - when (state) { - is ConnectionState.CONNECTING -> - Text( - text = "Connecting...", - color = Color.Yellow, - fontSize = 25.sp, - ) + is ConnectionState.Error -> { + Text("Error while connecting: ") + Text((state as ConnectionState.Error).message) + } - is ConnectionState.CONNECTED -> Text("Connected", color = Color.Green, fontSize = 25.sp) - is ConnectionState.Error -> { - Text("Error while connecting: ") - Text((state as ConnectionState.Error).message) + else -> {} } - - else -> {} } + + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, + ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt index 25ca258..f5829a1 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 @@ -2,6 +2,7 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -25,6 +26,7 @@ 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 @Suppress("ktlint:standard:function-naming") @@ -32,44 +34,52 @@ import com.codenames.frontend.ui.navigation.Screen fun UserNameScreen(navController: NavController) { var username by remember { mutableStateOf("") } - Column( + Box( modifier = Modifier .fillMaxSize() .background(Color(0xFFf0d8ce)), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = "Codenames", - fontSize = 48.sp, - modifier = Modifier.padding(bottom = 100.dp), - ) + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Codenames", + fontSize = 48.sp, + modifier = Modifier.padding(bottom = 100.dp), + ) - TextField( - value = username, - onValueChange = { username = it }, - label = { Text("enter username") }, - modifier = Modifier.fillMaxWidth(0.5f), - ) + TextField( + value = username, + onValueChange = { username = it }, + label = { Text("enter username") }, + modifier = Modifier.fillMaxWidth(0.5f), + ) + + Spacer(modifier = Modifier.height(10.dp)) - Spacer(modifier = Modifier.height(10.dp)) + AppButton( + text = "Continue", + onClick = { navController.navigate("${Screen.Start.route}/$username") }, + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = username.isNotBlank(), + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + } - AppButton( - text = "Continue", - onClick = { navController.navigate("${Screen.Start.route}/$username") }, - modifier = - Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = - AppButtonStyle( - enabled = username.isNotBlank(), - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, ) } } From c6f6805c8517b6661ea36cc2739410c21983277f Mon Sep 17 00:00:00 2001 From: XtophB Date: Thu, 7 May 2026 12:26:08 +0200 Subject: [PATCH 018/121] chore: add missing .mailmap file --- .mailmap | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..f459655 --- /dev/null +++ b/.mailmap @@ -0,0 +1,7 @@ +Christopher Budhiawan +Emre Orhan +Sofija Sternad-Gugnjak +Alexander Dermutz +Selina Prettner +Anna Pschernig +Natasa Jeftic \ No newline at end of file From b5d0bb0946319c6ef1c96c9ffdb97fd6147727d9 Mon Sep 17 00:00:00 2001 From: XtophB Date: Thu, 7 May 2026 12:33:16 +0200 Subject: [PATCH 019/121] fix: add correct email for Prettner --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index f459655..5d866b2 100644 --- a/.mailmap +++ b/.mailmap @@ -3,5 +3,6 @@ Emre Orhan Sofija Sternad-Gugnjak Alexander Dermutz Selina Prettner +Selina Prettner Anna Pschernig Natasa Jeftic \ No newline at end of file From 79fb167b73855175defb0bf1a50eb9b05bab6488 Mon Sep 17 00:00:00 2001 From: XtophB Date: Thu, 7 May 2026 12:44:06 +0200 Subject: [PATCH 020/121] fix: add second email for Budhiawan --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 5d866b2..c69b130 100644 --- a/.mailmap +++ b/.mailmap @@ -1,4 +1,5 @@ Christopher Budhiawan +Christopher Budhiawan Emre Orhan Sofija Sternad-Gugnjak Alexander Dermutz From 1451c54a234302660a7fd53576b7eadc2e7aa8c9 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Thu, 7 May 2026 17:27:13 +0200 Subject: [PATCH 021/121] Chatfenster UI implementation --- .idea/misc.xml | 1 + .../frontend/ui/screens/GameboardScreen.kt | 159 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 161 insertions(+), 1 deletion(-) 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/ui/screens/GameboardScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt index 42af587..28c3413 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 @@ -47,6 +47,7 @@ import com.codenames.frontend.ui.composables.GameBoardGrid import com.codenames.frontend.ui.inputs.AppTextField import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard import com.codenames.frontend.ui.inputs.AppTextFieldState +import com.codenames.frontend.ui.inputs.AppTextFieldStyle import com.codenames.frontend.ui.roles.PlayerRoles enum class CardType { @@ -124,6 +125,9 @@ fun GameboardScreen( onSettingsClick: (() -> Unit)? = null, ) { var hintInput by rememberSaveable { mutableStateOf("") } + var chatInput by rememberSaveable { mutableStateOf("") } + var isChatOpen by rememberSaveable { mutableStateOf(false) } + val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val keyboardController = LocalSoftwareKeyboardController.current @@ -199,6 +203,35 @@ fun GameboardScreen( ) } + if (!isSpymaster && isChatOpen) { + ChatWindow( + chatInput = chatInput, + onChatInputChange = { chatInput = it }, + onSendClick = { + chatInput = "" + focusManager.clearFocus() + keyboardController?.hide() + }, + modifier = + Modifier + .align(Alignment.Center) + .padding(end = 24.dp, bottom = 96.dp) + .width(420.dp) + .fillMaxHeight(0.78f), + ) + } + + if (!isSpymaster) { + ChatToggleButton( + isChatOpen = isChatOpen, + onClick = { isChatOpen = !isChatOpen }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 24.dp), + ) + } + onSettingsClick?.let { openSettings -> SettingsCornerButton( onClick = openSettings, @@ -207,6 +240,132 @@ fun GameboardScreen( } } +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatToggleButton( + isChatOpen: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AppButton( + text = "Chat", + onClick = onClick, + modifier = + modifier + .width(140.dp) + .height(56.dp), + style = + AppButtonStyle( + containerColor = if (isChatOpen) Color(0xFF555555) else Color(0xFF383330), + contentColor = Color.White, + fontSize = 18.sp, + lineHeight = 20.sp, + ), + ) +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatWindow( + chatInput: String, + onChatInputChange: (String) -> Unit, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .background( + color = Color(0xE6383330), + shape = RoundedCornerShape(12.dp), + ).padding(12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + ChatMessagesArea( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .height(64.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppTextField( + value = chatInput, + onValueChange = onChatInputChange, + modifier = + Modifier + .weight(1f) + .fillMaxHeight(), + state = + AppTextFieldState( + label = "Message", + placeholder = "Type message...", + ), + style = + AppTextFieldStyle( + containerColor = Color(0xFFE0D8C8), + contentColor = Color(0xFF383330), + fontSize = 14.sp, + lineHeight = 16.sp, + ), + ) + + AppButton( + text = "Send", + onClick = onSendClick, + modifier = + Modifier + .width(92.dp) + .fillMaxHeight(), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 16.sp, + lineHeight = 18.sp, + ), + ) + } + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatMessagesArea(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .background( + color = Color(0xB3E0D8C8), + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + verticalArrangement = Arrangement.Top, + ) { + Text( + text = "Team Chat", + color = Color(0xFF383330), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Messages will appear here.", + color = Color(0xFF383330), + fontSize = 14.sp, + ) + } +} + @Suppress("ktlint:standard:function-naming") @Composable fun TeamSidebar( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2302e13..95aabc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.1.0" +agp = "9.1.1" coreKtx = "1.18.0" hiltAndroidCompiler = "2.59.2" hiltNavigationCompose = "1.3.0" From b242d0384971e6a0b62bdad761a683be7635db6e Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 13:07:25 +0200 Subject: [PATCH 022/121] fix: updates to chat logs now no longer cause racec conditions --- .../data/repository/ChatRepository.kt | 2 +- .../frontend/viewmodel/GameViewModel.kt | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 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 70959bc..b3a243b 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 @@ -25,7 +25,7 @@ class ChatRepository sender = dto.senderUsername, text = dto.content, isFromMe = dto.senderUsername == currentUsername, - ), + ) ) } } 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 1f610d7..7ad161d 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -13,6 +13,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -28,7 +29,9 @@ class GameViewModel private val _uiState = MutableStateFlow(GameMessage()) val uiState: StateFlow = _uiState + // _chatState is mutable and should only be used by view model private val _chatState = MutableStateFlow(ChatLists()) + // chatState is not mutable and is meant for the UI val chatState: StateFlow = _chatState private val _connectionState = MutableStateFlow(ConnectionState.IDLE) @@ -59,30 +62,25 @@ class GameViewModel launch { chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> - val currentChatLists = _chatState.value - val updatedLobbyList = currentChatLists.lobbyMessages + msg - // we copy the entire ChatList object and update the entire list of where we are listening to then return the entire object - val newState = currentChatLists.copy(lobbyMessages = updatedLobbyList) - _chatState.value = newState + _chatState.update { currentState -> + currentState.copy(lobbyMessages = currentState.lobbyMessages + msg) + } } } launch { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> - val currentChatLists = _chatState.value - val updatedTeamList = currentChatLists.teamMessages + msg - val newState = currentChatLists.copy(teamMessages = updatedTeamList) - _chatState.value = newState - } + _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 -> - val currentChatLists = _chatState.value - val updatedOperativeList = currentChatLists.operativeMessages + msg - val newState = currentChatLists.copy(operativeMessages = updatedOperativeList) - _chatState.value = newState + _chatState.update { currentState -> + currentState.copy(operativeMessages = currentState.operativeMessages + msg) + } } } } From 38b8a7e65b93e8afa25244db30cf544ece87768a Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 13:27:14 +0200 Subject: [PATCH 023/121] chore: add comments for clarity --- .../com/codenames/frontend/data/repository/ChatRepository.kt | 2 +- .../main/java/com/codenames/frontend/viewmodel/GameViewModel.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 b3a243b..8e7842a 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 @@ -18,7 +18,7 @@ class ChatRepository ): 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( 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 7ad161d..a862ed8 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -61,6 +61,7 @@ class GameViewModel } launch { + // msg is the domain model chat we emit in the ChatRepository chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> _chatState.update { currentState -> currentState.copy(lobbyMessages = currentState.lobbyMessages + msg) From 5a4aeda1862d77be9c201900ac578cce5cfc937c Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 14:58:07 +0200 Subject: [PATCH 024/121] test: add test for ChatRepository --- .../data/repository/ChatRepositoryTest.kt | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.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 new file mode 100644 index 0000000..95b2c8d --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -0,0 +1,68 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + + +class ChatRepositoryTest { + private lateinit var webSocketHandler: GameWebSocketHandler + private lateinit var repository: ChatRepository + + private val testTopic = "/topic/chat" + private val testUser = "TestUser" + private val testContent = "Hello World" + private val testDto = ChatMessageDto(senderUsername = testUser, content = testContent) + + @Before + fun setup() { + webSocketHandler = mockk() + repository = ChatRepository(webSocketHandler) + // Suspend methods use coroutines (lighter version of thread) -> instead of every, we use coEvery + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns flowOf(testDto) + } + + @Test + fun testObserveChat_correctContent() = runTest { + // Since we mock the function to return a flow, we can call toList and grab all the contents + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(testContent, result[0].text) + } + + @Test + fun testObserveChat_correctUser() = runTest { + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(testUser, result[0].sender) + } + + @Test + fun testObserveChat_fromMe() = runTest { + val result = repository.observeChat(testTopic, testUser).toList() + assertTrue(result[0].isFromMe) + } + + @Test + fun testObserveChat_notFromMe() = runTest { + val result = repository.observeChat(testTopic, "NotTestUser").toList() + assertFalse(result[0].isFromMe) + } + + @Test + fun testSendMessage() = runTest{ + coEvery { webSocketHandler.sendChatMessage(testTopic, testDto) } just Runs + repository.sendMessage(testTopic, testUser, testContent) + coVerify { webSocketHandler.sendChatMessage(testTopic, testDto) } + } +} \ No newline at end of file From 8c44fa092319ca6b79351d6d8b6e960800d6299e Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 15:27:25 +0200 Subject: [PATCH 025/121] test: add setup method --- .../network/websocket/GameWebSocketHandlerTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt index 5df59a3..e8427ed 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 @@ -14,9 +14,22 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxS import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe +import org.junit.Before import org.junit.Test class GameWebSocketHandlerTest { + private lateinit var client: StompClient + private lateinit var session: StompSessionWithKxSerialization + private lateinit var wsClient: GameWebSocketHandler + + @Before + fun setup(){ + client = mockk() + session = mockk(relaxed = true) + wsClient = GameWebSocketHandler(client) + wsClient.session = session + } + @Test fun testConnectStomp() = runTest { From 5af2732a312127fd1faea7b8d6b21f56345e39c1 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 15:48:10 +0200 Subject: [PATCH 026/121] test: add tests for sending msg and subscribing --- .../websocket/GameWebSocketHandlerTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 e8427ed..80f71dd 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,5 +1,6 @@ package com.codenames.frontend.network.websocket +import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage @@ -112,4 +113,23 @@ class GameWebSocketHandlerTest { session.convertAndSend("app/1234/join", msg, WebSocketJoinMessage.serializer()) } } + + @Test + fun testSubscribeToChat() = runTest{ + val topic = "/topic/chat/123" + + wsClient.subscribeToChat(topic) + + coVerify { session.subscribe(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()) } + } } From 3d74db586dee2cc71d11a03fca2861df6f5bd9f0 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 16:02:04 +0200 Subject: [PATCH 027/121] fix: connect method now takes team and role --- .../main/java/com/codenames/frontend/ui/screens/StartScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ca34902..fee12cf 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 @@ -133,7 +133,7 @@ fun StartScreen( AppButton( text = "Connect to Server", onClick = { - viewModel.connect("TestUser", "12345") + viewModel.connect("TestUser", "12345", "RED", "Spymaster") }, modifier = Modifier From 70bbc24f5848d5d3e395f37a7a182fc6409141c8 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 16:03:39 +0200 Subject: [PATCH 028/121] refactor: delete duplicate code, promote reusability --- .../frontend/viewmodel/GameViewModelTest.kt | 73 +++++++------------ 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt index 5fa2e58..3f138fe 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.viewmodel +import com.codenames.frontend.data.repository.ChatRepository import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler @@ -25,13 +26,32 @@ import org.junit.Test class GameViewModelTest { private val testDispatcher = StandardTestDispatcher() + private val lobbyCode = "1234" + private val username = "user" + private val team = "RED" + private val role = "OPERATIVE" + + private val testMessage = + GameMessage( + "", + "red", + 0, + 0, + "", + 0, + emptyList(), + ) + private lateinit var viewModel: GameViewModel - private val client: GameWebSocketHandler = mockk(relaxed = true) + private lateinit var client: GameWebSocketHandler + private lateinit var chatRepository: ChatRepository @Before fun setup() { Dispatchers.setMain(testDispatcher) - viewModel = GameViewModel(client) + client = mockk(relaxed = true) + chatRepository = mockk(relaxed = true) + viewModel = GameViewModel(client, chatRepository) } @After @@ -42,26 +62,13 @@ class GameViewModelTest { @Test fun connect_shouldCallClientAndUpdateState() = runTest { - val lobbyCode = "1234" - val username = "user" - - val testMessage = - GameMessage( - "", - "red", - 0, - 0, - "", - 0, - emptyList(), - ) val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() @@ -74,30 +81,17 @@ class GameViewModelTest { @Test fun connect_shouldCallClientAndUpdateState_isAlreadyConnected() = runTest { - val lobbyCode = "1234" - val username = "user" - - val testMessage = - GameMessage( - "", - "red", - 0, - 0, - "", - 0, - emptyList(), - ) val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) coVerify { client.connectStomp() } coVerify { client.subscribeToLobby(lobbyCode) } @@ -108,19 +102,6 @@ class GameViewModelTest { @Test fun connect_shouldSendJoinMessage() = runTest { - val lobbyCode = "1234" - val username = "user" - - val testMessage = - GameMessage( - "", - "red", - 0, - 0, - "", - 0, - emptyList(), - ) val flow = flowOf(testMessage) @@ -128,10 +109,12 @@ class GameViewModelTest { coEvery { client.subscribeToLobby(lobbyCode) } returns flow coEvery { client.sendLobbyJoinMessage(any()) } just Runs - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() coVerify { client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) } } + + } From e4e9887adedbbca2ff13a4ca82228406f24ef86f Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 16:43:52 +0200 Subject: [PATCH 029/121] test: add test for sending msg --- .../frontend/viewmodel/GameViewModelTest.kt | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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 3f138fe..55da60a 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.viewmodel +import com.codenames.frontend.data.model.ChatDomainModel import com.codenames.frontend.data.repository.ChatRepository import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage @@ -7,10 +8,13 @@ import com.codenames.frontend.network.websocket.GameWebSocketHandler 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -116,5 +120,37 @@ class GameViewModelTest { coVerify { client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) } } - + @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) + } + } + } From b36c897f7ebddee57adae5e9eb27acc90c6832f2 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 16:53:09 +0200 Subject: [PATCH 030/121] test: add test for updates to Lists of chats --- .../frontend/viewmodel/GameViewModelTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 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 55da60a..a550336 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -153,4 +153,54 @@ class GameViewModelTest { } } + @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() + every { chatRepository.observeChat("/topic/chat/$lobbyCode", username) } returns customLobbyFlow + + 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 testConnectUpdateTeamChat() = runTest { + val customLobbyFlow = MutableSharedFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) } returns customLobbyFlow + + 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.teamMessages + assertEquals("Test msg", currentMessageList[0].text) + } + + @Test + fun testConnectUpdateOperativeChat() = runTest { + val customLobbyFlow = MutableSharedFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow + + 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.operativeMessages + assertEquals("Test msg", currentMessageList[0].text) + } + } From 02ffe183bff92ef8a7fffa0f62f9ad3639653efe Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 9 May 2026 21:27:28 +0200 Subject: [PATCH 031/121] fix: use same verify method as subscribe to lobby --- .../frontend/network/websocket/GameWebSocketHandlerTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 80f71dd..954bcc0 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 @@ -120,7 +120,12 @@ class GameWebSocketHandlerTest { wsClient.subscribeToChat(topic) - coVerify { session.subscribe(topic, ChatMessageDto.serializer()) } + coVerify { + session.subscribe( + match { it.destination == topic }, + ChatMessageDto.serializer() + ) + } } @Test From 74e35e1bd9ae4ef94114a6b48ebaa6fe1fe19a41 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 10 May 2026 12:07:03 +0200 Subject: [PATCH 032/121] chore: add comment for clarification on why variable exists --- .../com/codenames/frontend/data/repository/ChatRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8e7842a..9f13b49 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 @@ -14,7 +14,7 @@ class ChatRepository ) { fun observeChat( topic: String, - currentUsername: 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) From 52c656d7c990220e360a1183297c3c2798b25096 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 10 May 2026 12:09:10 +0200 Subject: [PATCH 033/121] test: add missing branch test for operative chat The source code has an if block around the observe for operative chat. If a non Operative tries to observe the operative chat, then the user will simply skip the if block, preventing them to eavesdropping on operative chat --- .../frontend/viewmodel/GameViewModelTest.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 a550336..d7d0c91 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -1,6 +1,8 @@ package com.codenames.frontend.viewmodel import com.codenames.frontend.data.model.ChatDomainModel +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.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage @@ -23,6 +25,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -32,8 +35,8 @@ class GameViewModelTest { private val lobbyCode = "1234" private val username = "user" - private val team = "RED" - private val role = "OPERATIVE" + private val team = Team.RED.name + private val role = Role.OPERATIVE.name private val testMessage = GameMessage( @@ -203,4 +206,20 @@ class GameViewModelTest { assertEquals("Test msg", currentMessageList[0].text) } + @Test + fun testConnectUpdateOperativeChat_notOperative() = runTest { + val customLobbyFlow = MutableSharedFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow + + viewModel.connect(username, lobbyCode, team, Role.SPYMASTER.name) + advanceUntilIdle() + + val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) + customLobbyFlow.emit(testChat) + advanceUntilIdle() + + val currentMessageList = viewModel.chatState.value.operativeMessages + assertTrue(currentMessageList.isEmpty()) + } + } From a2bb8220cd21657bb7946f6cebd7b9c6a59ebba4 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 10 May 2026 12:10:22 +0200 Subject: [PATCH 034/121] test: add missing branch coverage We first mock the flow to return an empty flow and assert an empty list Second test we mock it throwing and error and assert it fails --- .../data/repository/ChatRepositoryTest.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 95b2c8d..ba42c52 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 @@ -7,6 +7,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.just import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest @@ -15,7 +16,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test - +import kotlin.test.assertFailsWith class ChatRepositoryTest { private lateinit var webSocketHandler: GameWebSocketHandler @@ -65,4 +66,19 @@ class ChatRepositoryTest { repository.sendMessage(testTopic, testUser, testContent) coVerify { webSocketHandler.sendChatMessage(testTopic, testDto) } } + + @Test + fun testObserveChat_emptyFlow() = runTest{ + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns emptyFlow() + val result = repository.observeChat(testTopic, testUser).toList() + assertTrue(result.isEmpty()) + } + + @Test + fun testObserveChat_error() = runTest{ + coEvery { webSocketHandler.subscribeToChat(any()) } throws RuntimeException() + assertFailsWith { + repository.observeChat(testTopic, testUser).toList() + } + } } \ No newline at end of file From f0136d9f1c9beb588b7f3f6284bd606c66e1afb7 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 10 May 2026 12:28:09 +0200 Subject: [PATCH 035/121] refactor: ktlintFormat and ktlintCheck --- .../data/repository/ChatRepository.kt | 2 +- .../frontend/viewmodel/GameViewModel.kt | 4 +- .../data/repository/ChatRepositoryTest.kt | 73 +++++---- .../websocket/GameWebSocketHandlerTest.kt | 34 ++-- .../frontend/viewmodel/GameViewModelTest.kt | 146 +++++++++--------- 5 files changed, 136 insertions(+), 123 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 9f13b49..b37fe63 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 @@ -25,7 +25,7 @@ class ChatRepository sender = dto.senderUsername, text = dto.content, isFromMe = dto.senderUsername == currentUsername, - ) + ), ) } } 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 a862ed8..6d6651c 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -31,6 +31,7 @@ class GameViewModel // _chatState is mutable and should only be used by view model private val _chatState = MutableStateFlow(ChatLists()) + // chatState is not mutable and is meant for the UI val chatState: StateFlow = _chatState @@ -73,7 +74,8 @@ class GameViewModel chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> _chatState.update { currentState -> currentState.copy(teamMessages = currentState.teamMessages + msg) - } } + } + } } if (role == Role.OPERATIVE.name) { 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 ba42c52..292318c 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 @@ -36,49 +36,56 @@ class ChatRepositoryTest { } @Test - fun testObserveChat_correctContent() = runTest { - // Since we mock the function to return a flow, we can call toList and grab all the contents - val result = repository.observeChat(testTopic, testUser).toList() - assertEquals(testContent, result[0].text) - } + fun testObserveChat_correctContent() = + runTest { + // Since we mock the function to return a flow, we can call toList and grab all the contents + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(testContent, result[0].text) + } @Test - fun testObserveChat_correctUser() = runTest { - val result = repository.observeChat(testTopic, testUser).toList() - assertEquals(testUser, result[0].sender) - } + fun testObserveChat_correctUser() = + runTest { + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(testUser, result[0].sender) + } @Test - fun testObserveChat_fromMe() = runTest { - val result = repository.observeChat(testTopic, testUser).toList() - assertTrue(result[0].isFromMe) - } + fun testObserveChat_fromMe() = + runTest { + val result = repository.observeChat(testTopic, testUser).toList() + assertTrue(result[0].isFromMe) + } @Test - fun testObserveChat_notFromMe() = runTest { - val result = repository.observeChat(testTopic, "NotTestUser").toList() - assertFalse(result[0].isFromMe) - } + fun testObserveChat_notFromMe() = + runTest { + val result = repository.observeChat(testTopic, "NotTestUser").toList() + assertFalse(result[0].isFromMe) + } @Test - fun testSendMessage() = runTest{ - coEvery { webSocketHandler.sendChatMessage(testTopic, testDto) } just Runs - repository.sendMessage(testTopic, testUser, testContent) - coVerify { webSocketHandler.sendChatMessage(testTopic, testDto) } - } + fun testSendMessage() = + runTest { + coEvery { webSocketHandler.sendChatMessage(testTopic, testDto) } just Runs + repository.sendMessage(testTopic, testUser, testContent) + coVerify { webSocketHandler.sendChatMessage(testTopic, testDto) } + } @Test - fun testObserveChat_emptyFlow() = runTest{ - coEvery { webSocketHandler.subscribeToChat(testTopic) } returns emptyFlow() - val result = repository.observeChat(testTopic, testUser).toList() - assertTrue(result.isEmpty()) - } + fun testObserveChat_emptyFlow() = + runTest { + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns emptyFlow() + val result = repository.observeChat(testTopic, testUser).toList() + assertTrue(result.isEmpty()) + } @Test - fun testObserveChat_error() = runTest{ - coEvery { webSocketHandler.subscribeToChat(any()) } throws RuntimeException() - assertFailsWith { - repository.observeChat(testTopic, testUser).toList() + fun testObserveChat_error() = + runTest { + coEvery { webSocketHandler.subscribeToChat(any()) } throws RuntimeException() + assertFailsWith { + repository.observeChat(testTopic, testUser).toList() + } } - } -} \ 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 954bcc0..971a5ab 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 @@ -24,7 +24,7 @@ class GameWebSocketHandlerTest { private lateinit var wsClient: GameWebSocketHandler @Before - fun setup(){ + fun setup() { client = mockk() session = mockk(relaxed = true) wsClient = GameWebSocketHandler(client) @@ -115,26 +115,28 @@ class GameWebSocketHandlerTest { } @Test - fun testSubscribeToChat() = runTest{ - val topic = "/topic/chat/123" + fun testSubscribeToChat() = + runTest { + val topic = "/topic/chat/123" - wsClient.subscribeToChat(topic) + wsClient.subscribeToChat(topic) - coVerify { - session.subscribe( - match { it.destination == topic }, - ChatMessageDto.serializer() - ) + coVerify { + session.subscribe( + match { it.destination == topic }, + ChatMessageDto.serializer(), + ) + } } - } @Test - fun testSendMessage() = runTest{ - val destination = "app/chat/123" - val msg = ChatMessageDto("TestUser", "TestMsg") + fun testSendMessage() = + runTest { + val destination = "app/chat/123" + val msg = ChatMessageDto("TestUser", "TestMsg") - wsClient.sendChatMessage(destination, msg) + wsClient.sendChatMessage(destination, msg) - coVerify { session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } - } + coVerify { session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } + } } 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 d7d0c91..3a04836 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -16,7 +16,6 @@ import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -69,7 +68,6 @@ class GameViewModelTest { @Test fun connect_shouldCallClientAndUpdateState() = runTest { - val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs @@ -88,7 +86,6 @@ class GameViewModelTest { @Test fun connect_shouldCallClientAndUpdateState_isAlreadyConnected() = runTest { - val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs @@ -109,7 +106,6 @@ class GameViewModelTest { @Test fun connect_shouldSendJoinMessage() = runTest { - val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs @@ -124,102 +120,108 @@ class GameViewModelTest { } @Test - fun testSendLobbyMessage() = runTest { - val content = "Test msg" - viewModel.sendLobbyMessage(lobbyCode, username, content) - advanceUntilIdle() + fun testSendLobbyMessage() = + runTest { + val content = "Test msg" + viewModel.sendLobbyMessage(lobbyCode, username, content) + advanceUntilIdle() - coVerify { - chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) + } } - } @Test - fun testSendTeamMessage() = runTest { - val content = "Test msg" - viewModel.sendTeamMessage(lobbyCode, team, username, content) - advanceUntilIdle() + fun testSendTeamMessage() = + runTest { + val content = "Test msg" + viewModel.sendTeamMessage(lobbyCode, team, username, content) + advanceUntilIdle() - coVerify { - chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) + } } - } @Test - fun testSendOperativeMessage() = runTest { - val content = "Test msg" - viewModel.sendOperativeMessage(lobbyCode, team, username, content) - advanceUntilIdle() + fun testSendOperativeMessage() = + runTest { + val content = "Test msg" + viewModel.sendOperativeMessage(lobbyCode, team, username, content) + advanceUntilIdle() - coVerify { - chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) + 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() - every { chatRepository.observeChat("/topic/chat/$lobbyCode", username) } returns customLobbyFlow + 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() + every { chatRepository.observeChat("/topic/chat/$lobbyCode", username) } returns customLobbyFlow - viewModel.connect(username, lobbyCode, team, role) - advanceUntilIdle() + viewModel.connect(username, lobbyCode, team, role) + advanceUntilIdle() - val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) - customLobbyFlow.emit(testChat) - 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) - } + val currentMessageList = viewModel.chatState.value.lobbyMessages + assertEquals("Test msg", currentMessageList[0].text) + } @Test - fun testConnectUpdateTeamChat() = runTest { - val customLobbyFlow = MutableSharedFlow() - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) } returns customLobbyFlow + fun testConnectUpdateTeamChat() = + runTest { + val customLobbyFlow = MutableSharedFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) } returns customLobbyFlow - viewModel.connect(username, lobbyCode, team, role) - advanceUntilIdle() + viewModel.connect(username, lobbyCode, team, role) + advanceUntilIdle() - val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) - customLobbyFlow.emit(testChat) - advanceUntilIdle() + val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) + customLobbyFlow.emit(testChat) + advanceUntilIdle() - val currentMessageList = viewModel.chatState.value.teamMessages - assertEquals("Test msg", currentMessageList[0].text) - } + val currentMessageList = viewModel.chatState.value.teamMessages + assertEquals("Test msg", currentMessageList[0].text) + } @Test - fun testConnectUpdateOperativeChat() = runTest { - val customLobbyFlow = MutableSharedFlow() - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow + fun testConnectUpdateOperativeChat() = + runTest { + val customLobbyFlow = MutableSharedFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow - viewModel.connect(username, lobbyCode, team, role) - advanceUntilIdle() + viewModel.connect(username, lobbyCode, team, role) + advanceUntilIdle() - val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) - customLobbyFlow.emit(testChat) - advanceUntilIdle() + val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) + customLobbyFlow.emit(testChat) + advanceUntilIdle() - val currentMessageList = viewModel.chatState.value.operativeMessages - assertEquals("Test msg", currentMessageList[0].text) - } + val currentMessageList = viewModel.chatState.value.operativeMessages + assertEquals("Test msg", currentMessageList[0].text) + } @Test - fun testConnectUpdateOperativeChat_notOperative() = runTest { - val customLobbyFlow = MutableSharedFlow() - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow - - viewModel.connect(username, lobbyCode, team, Role.SPYMASTER.name) - advanceUntilIdle() + fun testConnectUpdateOperativeChat_notOperative() = + runTest { + val customLobbyFlow = MutableSharedFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow - val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) - customLobbyFlow.emit(testChat) - advanceUntilIdle() + viewModel.connect(username, lobbyCode, team, Role.SPYMASTER.name) + advanceUntilIdle() - val currentMessageList = viewModel.chatState.value.operativeMessages - assertTrue(currentMessageList.isEmpty()) - } + val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) + customLobbyFlow.emit(testChat) + advanceUntilIdle() + val currentMessageList = viewModel.chatState.value.operativeMessages + assertTrue(currentMessageList.isEmpty()) + } } From 58d8c385df276d3c1920303f277391b89b761972 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 10 May 2026 13:30:26 +0200 Subject: [PATCH 036/121] test: add additional tests for unhappy paths Original intention was to reach higher branch coverage for the ChatRepository class. The added test cases did not, however, increase the branch coverage. --- .../data/repository/ChatRepositoryTest.kt | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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 292318c..3e6e4fb 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 @@ -8,7 +8,9 @@ import io.mockk.coVerify import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -81,11 +83,58 @@ class ChatRepositoryTest { } @Test - fun testObserveChat_error() = + fun testObserveChat_multipleMessages() = + runTest { + val dtos = + flowOf( + ChatMessageDto(testUser, "First"), + ChatMessageDto("OtherUser", "Second"), + ) + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns dtos + + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(2, result.size) + } + + @Test + fun testObserveChat_webSocketError() = runTest { coEvery { webSocketHandler.subscribeToChat(any()) } throws RuntimeException() assertFailsWith { repository.observeChat(testTopic, testUser).toList() } } + + @Test + fun testObserveChat_errorDuringCollection() = + runTest { + val flowWithError = + flow { + emit(testDto) + throw RuntimeException("Connection Lost") + } + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns flowWithError + + val flow = repository.observeChat(testTopic, testUser) + assertFailsWith { + flow.toList() + } + } + + // Testing early cancellation of flow using .take(1) + @Test + fun testObserveChat_earlyCancellation() = + runTest { + val dtos = + flowOf( + ChatMessageDto(testUser, "First"), + ChatMessageDto("OtherUser", "Second"), + ) + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns dtos + + // take() will consume x elements and cancel the flow + val result = repository.observeChat(testTopic, testUser).take(1).toList() + + assertEquals(1, result.size) + } } From 3b06d91b37d4056e89735995466c724c64e3b6c8 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 12 May 2026 17:51:12 +0200 Subject: [PATCH 037/121] refactor: added comment to emtpy code block for sonar --- .../main/java/com/codenames/frontend/ui/screens/StartScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fee12cf..2c93239 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 @@ -168,7 +168,7 @@ fun StartScreen( Text((state as ConnectionState.Error).message) } - else -> {} + else -> { /* No message for other states */ } } } From aecf34da9fceb6d8ccc633a3b7d82b8ee22fc8c2 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 12 May 2026 18:05:53 +0200 Subject: [PATCH 038/121] refactor: extracted all gradients to new file Brush.kt, to avoid duplicated code --- .../frontend/ui/navigation/NavGraph.kt | 5 ++- .../frontend/ui/screens/GameboardScreen.kt | 3 ++ .../frontend/ui/screens/JoinlobbyScreen.kt | 11 +---- .../frontend/ui/screens/LobbyScreen.kt | 40 ++----------------- .../frontend/ui/screens/SettingsScreen.kt | 2 + .../frontend/ui/screens/StartScreen.kt | 22 ++-------- .../frontend/ui/screens/UserNameScreen.kt | 1 + .../com/codenames/frontend/ui/theme/Brush.kt | 40 +++++++++++++++++++ 8 files changed, 57 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt 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 91f9c27..d9d945e 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 @@ -43,8 +43,9 @@ fun NavGraph() { listOf( navArgument("username") { type = NavType.StringType }, ), - ) { - StartScreen(navController) + ) { backStackEntry -> + val username = backStackEntry.arguments?.getString("username") ?: "" + StartScreen(navController = navController, username = username) } composable(Screen.Lobby.route) { 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 28c3413..dc3aa66 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 @@ -49,6 +49,9 @@ 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.blueGradient +import com.codenames.frontend.ui.theme.greenGradient +import com.codenames.frontend.ui.theme.redGradient enum class CardType { BLUE, 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 5766599..7f6d04b 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 @@ -18,7 +18,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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -38,6 +37,7 @@ import com.codenames.frontend.ui.inputs.AppTextFieldState import com.codenames.frontend.ui.inputs.AppTextFieldStyle import com.codenames.frontend.ui.inputs.AppTextFieldType import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.theme.blueGradient internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" @@ -61,15 +61,6 @@ fun JoinlobbyScreen(navController: NavHostController) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) - val joinEnabled = isLobbyIdValid(lobbyId) fun submitJoin() { 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 313d6de..6135108 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 @@ -32,42 +32,10 @@ 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.roles.PlayerRoles - -val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) - -val redGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFFCF5530), - Color(0xFFDE8468), - ), - ) - -val brownGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF383330), - Color(0xFF1A1513), - ), - ) - -val greenGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), - ) +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.ui.theme.brownGradient +import com.codenames.frontend.ui.theme.greenGradient +import com.codenames.frontend.ui.theme.redGradient private const val JOIN_TEAM: String = "JOIN TEAM" private const val TEAM_JOINED: String = "👤 1 joined" 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 87ea87b..70cc208 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt @@ -20,6 +20,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.blueGradient +import com.codenames.frontend.ui.theme.greenGradient @Suppress("ktlint:standard:function-naming") @Composable 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 2c93239..86c7fcf 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 @@ -16,7 +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.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -27,35 +26,20 @@ import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.viewmodel.GameViewModel @Suppress("ktlint:standard:function-naming") @Composable fun StartScreen( navController: NavHostController, + username: String, viewModel: GameViewModel = hiltViewModel(), ) { val state by viewModel.connectionState.collectAsState() ForceLandscape() - val greenGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), - ) - - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) - Box( modifier = Modifier 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 f5829a1..01894fd 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 @@ -28,6 +28,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.blueGradient @Suppress("ktlint:standard:function-naming") @Composable diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt b/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt new file mode 100644 index 0000000..197b01c --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt @@ -0,0 +1,40 @@ +package com.codenames.frontend.ui.theme + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +val greenGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF4CAF50), + Color(0xFF2E7D32), + ), + ) + +val blueGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), + ) + +val redGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFCF5530), + Color(0xFFDE8468), + ), + ) + +val brownGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF383330), + Color(0xFF1A1513), + ), + ) From d1e8ea861ed2f765ab625f5455cf6347c04d07fe Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 12 May 2026 18:22:05 +0200 Subject: [PATCH 039/121] added username as parameter to start screen and welcome text --- .../main/java/com/codenames/frontend/ui/screens/StartScreen.kt | 2 ++ 1 file changed, 2 insertions(+) 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 86c7fcf..bca20f8 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 @@ -51,6 +51,8 @@ fun StartScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + Text("Welcome to Codenames, $username!", fontSize = 32.sp, modifier = Modifier.padding(bottom = 48.dp)) + Row( modifier = Modifier From 7e2388c3a133b2e41d9e5f77be10730e12a47e37 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 12 May 2026 20:58:20 +0200 Subject: [PATCH 040/121] improved check for lobby code validity, added backend calls to lobby join and create, to be tested --- .../frontend/data/model/SessionState.kt | 5 ++ .../frontend/ui/navigation/NavGraph.kt | 19 +++--- ...{JoinlobbyScreen.kt => JoinLobbyScreen.kt} | 35 ++++++++-- .../frontend/ui/screens/StartScreen.kt | 64 +++++-------------- .../frontend/ui/screens/UserNameScreen.kt | 13 +++- .../frontend/viewmodel/SessionViewModel.kt | 16 +++++ 6 files changed, 86 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/SessionState.kt rename app/src/main/java/com/codenames/frontend/ui/screens/{JoinlobbyScreen.kt => JoinLobbyScreen.kt} (78%) create mode 100644 app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt new file mode 100644 index 0000000..95dca09 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt @@ -0,0 +1,5 @@ +package com.codenames.frontend.data.model + +data class SessionState( + val username: String, +) diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt index d9d945e..87482a1 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 @@ -18,7 +18,7 @@ import com.codenames.frontend.ui.screens.GameCard import com.codenames.frontend.ui.screens.GameSettingsScreen import com.codenames.frontend.ui.screens.GameTestScreen import com.codenames.frontend.ui.screens.GameboardScreen -import com.codenames.frontend.ui.screens.JoinlobbyScreen +import com.codenames.frontend.ui.screens.JoinLobbyScreen import com.codenames.frontend.ui.screens.LobbyScreen import com.codenames.frontend.ui.screens.SettingsScreen import com.codenames.frontend.ui.screens.StartScreen @@ -38,22 +38,19 @@ fun NavGraph() { } composable( - route = "${Screen.Start.route}/{username}", - arguments = - listOf( - navArgument("username") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val username = backStackEntry.arguments?.getString("username") ?: "" - StartScreen(navController = navController, username = username) + Screen.Start.route, + ) { + StartScreen(navController = navController) } composable(Screen.Lobby.route) { LobbyScreen(navController) } - composable(Screen.JoinLobby.route) { - JoinlobbyScreen(navController) + composable( + route = Screen.JoinLobby.route, + ) { + JoinLobbyScreen(navController) } composable( 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 similarity index 78% rename from app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt rename to app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt index 7f6d04b..6ef95c4 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 @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -27,6 +29,8 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -38,22 +42,31 @@ import com.codenames.frontend.ui.inputs.AppTextFieldStyle import com.codenames.frontend.ui.inputs.AppTextFieldType import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" -private const val LOBBY_ID_MAX_LENGTH = 12 +private const val LOBBY_ID_LENGTH = 5 // lobby id hat genau 5 Zeichen -internal fun sanitizeLobbyIdInput(input: String): String = +private fun sanitizeLobbyIdInput(input: String): String = input .uppercase() .filter { it.isLetterOrDigit() } - .take(LOBBY_ID_MAX_LENGTH) + .take(LOBBY_ID_LENGTH) -internal fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() +fun isLobbyIdValid(lobbyId: String): Boolean = + lobbyId.isNotBlank() && + lobbyId.length == LOBBY_ID_LENGTH && + lobbyId.matches("^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$".toRegex()) @Suppress("ktlint:standard:function-naming") @Composable -fun JoinlobbyScreen(navController: NavHostController) { +fun JoinLobbyScreen( + navController: NavHostController, + viewModel: LobbyViewModel = viewModel(), + sessionViewModel: SessionViewModel = hiltViewModel(), +) { ForceLandscape() var lobbyId by rememberSaveable { mutableStateOf("") } @@ -61,15 +74,25 @@ fun JoinlobbyScreen(navController: NavHostController) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + val state by viewModel.state.collectAsState() + val username by sessionViewModel.username.collectAsState() + val joinEnabled = isLobbyIdValid(lobbyId) + // Navigation wird ausgeführt, wenn alle notwendigen states im Lobby UI state gesetzt sind. Wird bei jeder rekomposition der UI durchlaufen + LaunchedEffect(state.lobbyCode, state.error, state.isLoading) { + if (!state.isLoading && state.error == null && state.lobbyCode != null) { + navController.navigate(Screen.Lobby.route) + } + } + fun submitJoin() { if (!joinEnabled) return keyboardController?.hide() focusManager.clearFocus() - // Hier später den echten Frontend-Join-Flow anschließen. + viewModel.joinLobby(username.username, lobbyId) } Box( 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 bca20f8..6c19496 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -20,26 +21,35 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController -import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient -import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel @Suppress("ktlint:standard:function-naming") @Composable fun StartScreen( navController: NavHostController, - username: String, - viewModel: GameViewModel = hiltViewModel(), + viewModel: LobbyViewModel = hiltViewModel(), + sessionViewModel: SessionViewModel = viewModel(), ) { - val state by viewModel.connectionState.collectAsState() ForceLandscape() + val state by viewModel.state.collectAsState() + val usernameState by sessionViewModel.username.collectAsState() + + LaunchedEffect(state.lobbyCode, state.error, state.isLoading) { + if (!state.isLoading && state.error == null && state.lobbyCode != null) { + navController.navigate(Screen.Lobby.route) + } + } + Box( modifier = Modifier @@ -51,7 +61,7 @@ fun StartScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Welcome to Codenames, $username!", fontSize = 32.sp, modifier = Modifier.padding(bottom = 48.dp)) + Text("Welcome to Codenames, ${usernameState.username}!", fontSize = 32.sp, modifier = Modifier.padding(bottom = 48.dp)) Row( modifier = @@ -62,6 +72,7 @@ fun StartScreen( AppButton( text = "Create Lobby", onClick = { + viewModel.createLobby(usernameState.username) navController.navigate(Screen.Lobby.route) }, modifier = @@ -115,47 +126,6 @@ fun StartScreen( lineHeight = 30.sp, ), ) - - AppButton( - text = "Connect to Server", - onClick = { - viewModel.connect("TestUser", "12345", "RED", "Spymaster") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) - - when (state) { - is ConnectionState.CONNECTING -> - Text( - text = "Connecting...", - color = Color.Yellow, - fontSize = 25.sp, - ) - - is ConnectionState.CONNECTED -> - Text( - text = "Connected", - color = Color.Green, - fontSize = 25.sp, - ) - - is ConnectionState.Error -> { - Text("Error while connecting: ") - Text((state as ConnectionState.Error).message) - } - - else -> { /* No message for other states */ } - } } SettingsCornerButton( 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 01894fd..ec400dc 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 @@ -23,16 +23,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.viewmodel.SessionViewModel @Suppress("ktlint:standard:function-naming") @Composable -fun UserNameScreen(navController: NavController) { +fun UserNameScreen( + navController: NavController, + viewModel: SessionViewModel = hiltViewModel(), +) { var username by remember { mutableStateOf("") } Box( @@ -63,7 +68,11 @@ fun UserNameScreen(navController: NavController) { AppButton( text = "Continue", - onClick = { navController.navigate("${Screen.Start.route}/$username") }, + onClick = { + if (username.isBlank()) return@AppButton + viewModel.setUsername(username) + navController.navigate(Screen.Start.route) + }, modifier = Modifier .width(220.dp) diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt new file mode 100644 index 0000000..cc098ca --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -0,0 +1,16 @@ +package com.codenames.frontend.viewmodel + +import androidx.lifecycle.ViewModel +import com.codenames.frontend.data.model.SessionState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class SessionViewModel : ViewModel() { + private val _username = MutableStateFlow(SessionState("")) + val username: StateFlow = _username + + fun setUsername(username: String) { + _username.update { SessionState(username) } + } +} From f8f266d7c90ede2cc04e381b0e475423ee91ff94 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 12:28:55 +0200 Subject: [PATCH 041/121] fix: switched from route arguments to hiltViewModel for username state, had to bind viewModel to main nav graph --- .idea/misc.xml | 1 - .../codenames/frontend/network/provider/RetrofitProvider.kt | 2 +- .../frontend/network/websocket/GameWebSocketHandler.kt | 3 ++- .../java/com/codenames/frontend/ui/navigation/NavGraph.kt | 1 + .../java/com/codenames/frontend/ui/screens/StartScreen.kt | 4 ++-- .../com/codenames/frontend/ui/screens/UserNameScreen.kt | 4 +++- .../com/codenames/frontend/viewmodel/SessionViewModel.kt | 6 +++++- 7 files changed, 14 insertions(+), 7 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/provider/RetrofitProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt index 51b2dae..0e6723a 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://localhost:8080/lobby" +const val BASE_URL = "http://192.168.0.134:8080/lobby/" @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 4ba6227..1e0390f 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 @@ -12,7 +12,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConver import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject -const val BASE_URL = "ws://localhost:8080/ws" +const val BASE_URL = "ws://192.168.0.134:8080/ws" class GameWebSocketHandler @Inject @@ -39,6 +39,7 @@ class GameWebSocketHandler session.convertAndSend("app/$lobbyCode/join", msg, WebSocketJoinMessage.serializer()) } + @Suppress("kotlin:S6309") suspend fun subscribeToChat(topicPath: String): Flow = session.subscribe(topicPath, ChatMessageDto.serializer()) suspend fun sendChatMessage( 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 87482a1..67f563a 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 @@ -32,6 +32,7 @@ fun NavGraph() { NavHost( navController = navController, startDestination = Screen.Username.route, + route = "main_graph", ) { composable(Screen.Username.route) { UserNameScreen(navController) 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 6c19496..92fccbf 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 @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,7 +38,7 @@ import com.codenames.frontend.viewmodel.SessionViewModel fun StartScreen( navController: NavHostController, viewModel: LobbyViewModel = hiltViewModel(), - sessionViewModel: SessionViewModel = viewModel(), + sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), ) { ForceLandscape() @@ -73,7 +74,6 @@ fun StartScreen( text = "Create Lobby", onClick = { viewModel.createLobby(usernameState.username) - navController.navigate(Screen.Lobby.route) }, modifier = Modifier 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 ec400dc..2d55411 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 @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +25,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -36,7 +38,7 @@ import com.codenames.frontend.viewmodel.SessionViewModel @Composable fun UserNameScreen( navController: NavController, - viewModel: SessionViewModel = hiltViewModel(), + viewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), ) { var username by remember { mutableStateOf("") } 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 cc098ca..a75adc3 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -1,12 +1,16 @@ package com.codenames.frontend.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import com.codenames.frontend.data.model.SessionState +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import javax.inject.Inject -class SessionViewModel : ViewModel() { +@HiltViewModel +class SessionViewModel @Inject constructor(): ViewModel() { private val _username = MutableStateFlow(SessionState("")) val username: StateFlow = _username From a8f522f58e503857939af939d0bae31ec8be5c9f Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 13:18:54 +0200 Subject: [PATCH 042/121] can now create a lobby, team and role are set null by default, user has to join a team by themselves --- .../com/codenames/frontend/data/repository/LobbyRepository.kt | 4 +++- .../main/java/com/codenames/frontend/network/dto/PlayerDto.kt | 4 ++-- .../codenames/frontend/network/provider/RetrofitProvider.kt | 2 +- .../java/com/codenames/frontend/viewmodel/LobbyViewModel.kt | 3 +++ .../java/com/codenames/frontend/viewmodel/SessionViewModel.kt | 1 - 5 files changed, 9 insertions(+), 5 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 622046c..30c64cf 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 @@ -12,7 +12,9 @@ class LobbyRepository constructor( private val api: LobbyApi, ) { - suspend fun createLobby(username: String): LobbyResponse = api.createLobby(username) + suspend fun createLobby(username: String): LobbyResponse{ + return api.createLobby(username) + } suspend fun leaveLobby( username: String, diff --git a/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt index d3a367b..c663fa7 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable @Serializable data class PlayerDto( val username: String, - val role: Role?, - val team: Team?, + val role: Role? = null, + val team: Team? = null, val isHost: Boolean, ) diff --git a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt index 0e6723a..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://192.168.0.134:8080/lobby/" +const val BASE_URL = "http://192.168.0.134:8080/" @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt index 9bf0535..c8edc7f 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.LobbyUiState @@ -45,8 +46,10 @@ class LobbyViewModel response.toLobbyState() } startPolling(response.lobbyCode) + Log.d("LobbyViewModel", "Lobby created: ${response.lobbyCode}") } catch (e: Exception) { setError(e.message) + Log.e("LobbyViewModel", "Error creating lobby: ${e.message}") } finally { setLoading(false) } 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 a75adc3..95a546d 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -1,6 +1,5 @@ package com.codenames.frontend.viewmodel -import android.util.Log import androidx.lifecycle.ViewModel import com.codenames.frontend.data.model.SessionState import dagger.hilt.android.lifecycle.HiltViewModel From 3898b076e6fafc37db10a3b05a3b984e6ed3c4c2 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 13:39:16 +0200 Subject: [PATCH 043/121] added owner to session view model in JoinLobbyScreen, lobby join works now --- .../java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 6ef95c4..8c7cd2e 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 @@ -64,8 +64,8 @@ fun isLobbyIdValid(lobbyId: String): Boolean = @Composable fun JoinLobbyScreen( navController: NavHostController, - viewModel: LobbyViewModel = viewModel(), - sessionViewModel: SessionViewModel = hiltViewModel(), + viewModel: LobbyViewModel = hiltViewModel(), + sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), ) { ForceLandscape() From 94f3129174f325f5357b4b5308b91913b34f99e1 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 17:50:49 +0200 Subject: [PATCH 044/121] added backend call to role change logic, had to change some data types to fit ui needs --- .../frontend/data/model/LobbyUiState.kt | 4 + .../frontend/network/api/LobbyApi.kt | 2 +- .../frontend/ui/navigation/NavGraph.kt | 86 +++---------------- .../frontend/ui/screens/GameScreenWrapper.kt | 56 ++++++++++++ .../frontend/ui/screens/LobbyScreen.kt | 47 +++++----- .../frontend/ui/screens/StartScreen.kt | 2 +- .../frontend/viewmodel/LobbyViewModel.kt | 55 +++++++++++- 7 files changed, 152 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt b/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt index f21a9c4..195786c 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt @@ -6,4 +6,8 @@ data class LobbyUiState( val players: List = emptyList(), val error: String? = null, val isGameStarted: Boolean = false, + val blueOperatives: List = emptyList(), + val blueSpymasters: List = emptyList(), + val redOperatives: List = emptyList(), + val redSpymasters: List = emptyList(), ) diff --git a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt index 4057ad7..85a4454 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 @@ -32,7 +32,7 @@ interface LobbyApi { @Path("lobbyCode") lobbyCode: String, ): LobbyResponse - @POST("lobby/{lobbyCode}/roleChange") + @POST("lobby/{lobbyCode}/select-position") suspend fun changeRole( @Path("lobbyCode") lobbyCode: String, @Body playerDto: PlayerDto, 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 67f563a..eb59f3d 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,33 +1,28 @@ package com.codenames.frontend.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.navigation.NavHostController -import androidx.navigation.NavType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.screens.CardType -import com.codenames.frontend.ui.screens.GameCard +import com.codenames.frontend.ui.screens.GameScreenWrapper import com.codenames.frontend.ui.screens.GameSettingsScreen import com.codenames.frontend.ui.screens.GameTestScreen -import com.codenames.frontend.ui.screens.GameboardScreen import com.codenames.frontend.ui.screens.JoinLobbyScreen import com.codenames.frontend.ui.screens.LobbyScreen import com.codenames.frontend.ui.screens.SettingsScreen import com.codenames.frontend.ui.screens.StartScreen import com.codenames.frontend.ui.screens.UserNameScreen +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel @Composable @Suppress("ktlint:standard:function-naming") -fun NavGraph() { +fun NavGraph(viewModel: LobbyViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel()) { val navController = rememberNavController() + val usernameState by sessionViewModel.username.collectAsState() NavHost( navController = navController, @@ -55,25 +50,13 @@ fun NavGraph() { } composable( - route = "${Screen.Gameboard.route}/{role}", - arguments = - listOf( - navArgument("role") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val roleString = - backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name - - val passedRole = - try { - PlayerRoles.valueOf(roleString) - } catch (e: IllegalArgumentException) { - PlayerRoles.NONE - } + route = Screen.Gameboard.route + ) { + val currentRole = viewModel.getRoleForUser(usernameState.username) GameScreenWrapper( navController = navController, - userRole = passedRole, + userRole = currentRole, ) } @@ -89,49 +72,4 @@ fun NavGraph() { GameTestScreen() } } -} - -@Composable -@Suppress("ktlint:standard:function-naming") -fun GameScreenWrapper( - navController: NavHostController, - userRole: PlayerRoles, -) { - var currentHint by remember { mutableStateOf("Waiting for hint...") } - - val cards = - remember { - mutableStateListOf( - *List(25) { - GameCard( - word = "Word ${it + 1}", - type = - when (it) { - 0 -> CardType.ASSASSIN - in 1..8 -> CardType.BLUE - in 9..15 -> CardType.RED - else -> CardType.NEUTRAL - }, - ) - }.toTypedArray(), - ) - } - - fun revealCard(index: Int) { - val card = cards[index] - cards[index] = card.copy(revealed = true) - } - - GameboardScreen( - userRole = userRole, - currentHint = currentHint, - onHintChange = { currentHint = it }, - cards = cards, - onReveal = { index -> - revealCard(index) - }, - onSettingsClick = { - navController.navigate(Screen.Settings.route) - }, - ) -} +} \ 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 new file mode 100644 index 0000000..ab1751a --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -0,0 +1,56 @@ +package com.codenames.frontend.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation.NavHostController +import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.roles.PlayerRoles + +@Composable +@Suppress("ktlint:standard:function-naming") +fun GameScreenWrapper( + navController: NavHostController, + userRole: PlayerRoles, +) { + var currentHint by remember { mutableStateOf("Waiting for hint...") } + + val cards = + remember { + mutableStateListOf( + *List(25) { + GameCard( + word = "Word ${it + 1}", + type = + when (it) { + 0 -> CardType.ASSASSIN + in 1..8 -> CardType.BLUE + in 9..15 -> CardType.RED + else -> CardType.NEUTRAL + }, + ) + }.toTypedArray(), + ) + } + + fun revealCard(index: Int) { + val card = cards[index] + cards[index] = card.copy(revealed = true) + } + + GameboardScreen( + userRole = userRole, + currentHint = currentHint, + onHintChange = { currentHint = it }, + cards = cards, + onReveal = { index -> + revealCard(index) + }, + onSettingsClick = { + navController.navigate(Screen.Settings.route) + }, + ) +} 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 6135108..8f95a3e 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,10 +14,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -24,8 +23,11 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavHostController +import com.codenames.frontend.data.model.LobbyUiState +import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -36,14 +38,17 @@ import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.brownGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel private const val JOIN_TEAM: String = "JOIN TEAM" private const val TEAM_JOINED: String = "👤 1 joined" @Suppress("ktlint:standard:function-naming") @Composable -fun LobbyScreen(navController: NavHostController) { - var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } +fun LobbyScreen(navController: NavHostController, viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph"))) { + val usernameState by sessionViewModel.username.collectAsState() + val lobbyUiState by viewModel.state.collectAsState() Box( modifier = @@ -65,12 +70,11 @@ fun LobbyScreen(navController: NavHostController) { gradient = blueGradient, textColor = Color(0xFF42A5F5), title = "BLUE TEAM", - currentRole = currentRole, - onRoleSelect = { currentRole = it }, + onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, + lobbyUiState = lobbyUiState, ) GameSettingsColumn( - currentRole = currentRole, navController = navController, ) @@ -80,8 +84,8 @@ fun LobbyScreen(navController: NavHostController) { gradient = redGradient, textColor = Color(0xFFDE8468), title = "RED TEAM", - currentRole = currentRole, - onRoleSelect = { currentRole = it }, + onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, + lobbyUiState = lobbyUiState, ) } @@ -99,8 +103,8 @@ fun TeamColumn( gradient: Brush, textColor: Color, title: String, - currentRole: PlayerRoles, onRoleSelect: (PlayerRoles) -> Unit, + lobbyUiState: LobbyUiState, ) { val align = if (color == Team.RED) Alignment.End else Alignment.Start Column( @@ -133,18 +137,18 @@ fun TeamColumn( RoleCard( role = if (color == Team.RED) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE, - currentRole = currentRole, onRoleSelect = onRoleSelect, modifier = cardModifier, title = "OPERATIVES", + players = if(color == Team.RED) lobbyUiState.redOperatives else lobbyUiState.blueOperatives, ) RoleCard( role = if (color == Team.RED) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER, - currentRole = currentRole, onRoleSelect = onRoleSelect, modifier = cardModifier, title = "SPYMASTERS", + players = if(color == Team.RED) lobbyUiState.redSpymasters else lobbyUiState.blueSpymasters, ) } } @@ -153,10 +157,10 @@ fun TeamColumn( @Composable fun RoleCard( role: PlayerRoles, - currentRole: PlayerRoles, onRoleSelect: (PlayerRoles) -> Unit, modifier: Modifier, title: String, + players: List = emptyList(), ) { Column( modifier = modifier, @@ -164,13 +168,9 @@ fun RoleCard( verticalArrangement = Arrangement.SpaceBetween, ) { Text(title, color = Color.White, fontWeight = FontWeight.Bold) - - if (currentRole == role) { - Text( - text = TEAM_JOINED, - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) + Log.d("LobbyScreen", "Players: $players") + for (player in players) { + Text(player, color = Color.White) } AppButton( @@ -188,7 +188,6 @@ fun RoleCard( @Suppress("ktlint:standard:function-naming") @Composable fun GameSettingsColumn( - currentRole: PlayerRoles, navController: NavController, ) { Column( @@ -234,7 +233,7 @@ fun GameSettingsColumn( AppButton( text = "START GAME", onClick = { - navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") + navController.navigate(Screen.Gameboard.route) }, modifier = Modifier @@ -252,3 +251,5 @@ fun GameSettingsColumn( ) } } + + 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 92fccbf..050bfaa 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 @@ -37,7 +37,7 @@ import com.codenames.frontend.viewmodel.SessionViewModel @Composable fun StartScreen( navController: NavHostController, - viewModel: LobbyViewModel = hiltViewModel(), + viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), ) { ForceLandscape() 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 c8edc7f..ff6eb47 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -4,10 +4,12 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.LobbyUiState +import com.codenames.frontend.data.model.Player import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.model.toLobbyState import com.codenames.frontend.data.repository.LobbyRepository +import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -47,6 +49,7 @@ class LobbyViewModel } startPolling(response.lobbyCode) Log.d("LobbyViewModel", "Lobby created: ${response.lobbyCode}") + Log.d("LobbyViewModel", "UI State: ${_state.value}") } catch (e: Exception) { setError(e.message) Log.e("LobbyViewModel", "Error creating lobby: ${e.message}") @@ -111,13 +114,15 @@ class LobbyViewModel } fun changeRole( - username: String, role: Role, team: Team, + username: String ) { + Log.d("LobbyViewModel", "Changing role to: $role, team: $team, username: $username") val lobbyCode = _state.value.lobbyCode if (lobbyCode.isNullOrBlank()) { setError("Not in a Lobby") + Log.d("LobbyViewModel", "Not in a lobby") return } @@ -130,14 +135,62 @@ class LobbyViewModel _state.update { response.toLobbyState() } + updateUiState(_state.value.players) + Log.d("LobbyViewModel", "Role changed: $role") + Log.d("LobbyViewModel", "Team changed: $team") + Log.d("LobbyViewModel", "UI State: ${_state.value}") } catch (e: Exception) { setError(e.message) + Log.e("LobbyViewModel", "Error changing role: ${e.message}") } finally { setLoading(false) } } } + fun changeRole(role: PlayerRoles, username: String) { + when(role) { + PlayerRoles.BLUE_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.BLUE, username = username) + PlayerRoles.RED_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.RED, username = username) + PlayerRoles.BLUE_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.BLUE, username = username) + PlayerRoles.RED_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.RED, username = username) + else -> setError("Invalid role") + } + } + + fun getRoleForUser(username: String) : PlayerRoles { + val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return PlayerRoles.NONE + return when(player.role) { + Role.OPERATIVE -> if(player.team == Team.BLUE) PlayerRoles.BLUE_OPERATIVE else PlayerRoles.RED_OPERATIVE + Role.SPYMASTER -> if(player.team == Team.BLUE) PlayerRoles.BLUE_SPYMASTER else PlayerRoles.RED_SPYMASTER + null -> PlayerRoles.NONE + } + } + + private fun updateUiState(players: List) { + _state.update { + it.copy( + blueOperatives = players + .filter { p -> p.team == Team.BLUE && p.role == Role.OPERATIVE } + .map { it.name }, + + blueSpymasters = players + .filter { p -> p.team == Team.BLUE && p.role == Role.SPYMASTER } + .map { it.name }, + + redOperatives = players + .filter { p -> p.team == Team.RED && p.role == Role.OPERATIVE } + .map { it.name }, + + redSpymasters = players + .filter { p -> p.team == Team.RED && p.role == Role.SPYMASTER } + .map { it.name }, + ) + } + } + + + private fun startPolling(lobbyCode: String) { if (pollingJob != null) return From cc53b54b4d3e931589be088cd46b906e977bd521 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 19:26:06 +0200 Subject: [PATCH 045/121] changes to Lobby UI: - added leave lobby button - changed settings button to only display icon for more room for content - added lobby code to screen - changed modifier of game settings column to fit the screen better --- .../com/codenames/frontend/MainActivity.kt | 10 +++ .../ui/buttons/SettingsCornerButton.kt | 23 ++++-- .../frontend/ui/screens/LobbyScreen.kt | 71 ++++++++++++++++--- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/MainActivity.kt b/app/src/main/java/com/codenames/frontend/MainActivity.kt index 7e328b3..58679c6 100644 --- a/app/src/main/java/com/codenames/frontend/MainActivity.kt +++ b/app/src/main/java/com/codenames/frontend/MainActivity.kt @@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import com.codenames.frontend.ui.navigation.NavGraph import com.codenames.frontend.ui.theme.CodenamesTheme import dagger.hilt.android.AndroidEntryPoint @@ -14,6 +16,14 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + setContent { CodenamesTheme { AppContent() diff --git a/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt index 187d30e..00e00a9 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 @@ -1,11 +1,14 @@ package com.codenames.frontend.ui.buttons +import android.widget.Button import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,17 +26,25 @@ fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 16.dp) - .width(140.dp) + .padding(top = 8.dp, end = 8.dp) + .width(56.dp) .height(56.dp) .zIndex(1f), ) { - AppButton( - text = "Settings", + androidx.compose.material3.IconButton( onClick = onClick, modifier = Modifier.fillMaxSize(), - style = cornerButtonStyle(), - ) + colors = androidx.compose.material3.IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Default.Settings, + contentDescription = "Settings", + tint = Color.White, + ) + } } } 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 8f95a3e..aeec976 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 @@ -5,7 +5,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,9 +24,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.NavHostController import com.codenames.frontend.data.model.LobbyUiState @@ -31,6 +36,7 @@ import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.AppButtonType import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles @@ -60,7 +66,7 @@ fun LobbyScreen(navController: NavHostController, viewModel: LobbyViewModel = hi modifier = Modifier .fillMaxSize() - .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding(top = 40.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -75,7 +81,11 @@ fun LobbyScreen(navController: NavHostController, viewModel: LobbyViewModel = hi ) GameSettingsColumn( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxHeight(), navController = navController, + lobbyCode = lobbyUiState.lobbyCode ?: "" ) TeamColumn( @@ -108,7 +118,8 @@ fun TeamColumn( ) { val align = if (color == Team.RED) Alignment.End else Alignment.Start Column( - modifier = modifier, + modifier = modifier + .fillMaxWidth(0.5f), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -188,19 +199,34 @@ fun RoleCard( @Suppress("ktlint:standard:function-naming") @Composable fun GameSettingsColumn( + modifier: Modifier, navController: NavController, + lobbyCode: String, + viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), + sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), ) { + val usernameState by sessionViewModel.username.collectAsState() Column( - modifier = Modifier.padding(horizontal = 24.dp), - verticalArrangement = Arrangement.Center, + modifier = modifier, + verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, ) { + Text( + text = "LOBBY CODE: $lobbyCode", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + Column( modifier = Modifier .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(250.dp) .fillMaxWidth(0.5f) .background(brownGradient, RoundedCornerShape(12.dp)) .padding(16.dp), @@ -230,6 +256,8 @@ fun GameSettingsColumn( ) } + Spacer(modifier = Modifier.weight(1f)) + AppButton( text = "START GAME", onClick = { @@ -238,17 +266,38 @@ fun GameSettingsColumn( modifier = Modifier .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(70.dp) .fillMaxWidth(0.5f) - .padding(top = 12.dp), + .padding(top = 16.dp), style = AppButtonStyle( backgroundBrush = greenGradient, - fontSize = 28.sp, - lineHeight = 30.sp, + fontSize = 20.sp, + type = AppButtonType.PRIMARY, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), ), ) + + AppButton( + text = "LEAVE LOBBY", + onClick = { + viewModel.leaveLobby(username = usernameState.username) + }, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.5f) + .padding(top = 16.dp) + .padding(bottom = 16.dp), + style = + AppButtonStyle( + backgroundBrush = brownGradient, + fontSize = 20.sp, + contentColor = Color.Black, + type = AppButtonType.SECONDARY, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + ) + + ) } } From 64f7ce30972c14c885200bed86d75e7354ca4666 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 20:00:41 +0200 Subject: [PATCH 046/121] fix: polling works now --- .../com/codenames/frontend/network/api/LobbyApi.kt | 4 ++-- .../codenames/frontend/ui/screens/LobbyScreen.kt | 5 ++++- .../codenames/frontend/viewmodel/LobbyViewModel.kt | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) 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 85a4454..c5fb3ad 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 @@ -26,10 +26,10 @@ interface LobbyApi { @Path("lobbyCode") lobbyCode: String, ): LobbyResponse - @POST("lobby/{lobbyCode}/{username}/leave") + @POST("lobby/{lobbyCode}/leave") suspend fun leaveLobby( - @Path("username") username: String, @Path("lobbyCode") lobbyCode: String, + @Query("username") username: String, ): LobbyResponse @POST("lobby/{lobbyCode}/select-position") 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 aeec976..3200bb0 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 @@ -280,7 +280,10 @@ fun GameSettingsColumn( AppButton( text = "LEAVE LOBBY", onClick = { - viewModel.leaveLobby(username = usernameState.username) + val successful = viewModel.leaveLobby(username = usernameState.username) + if(successful) navController.navigate(Screen.Start.route) { + popUpTo(Screen.Start.route) { inclusive = true } + } }, 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 ff6eb47..009d722 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -87,12 +87,13 @@ class LobbyViewModel } } - fun leaveLobby(username: String) { + fun leaveLobby(username: String) : Boolean{ val lobbyCode = _state.value.lobbyCode + var successful = false if (lobbyCode.isNullOrBlank()) { setError("Not in a lobby, leaving not possible") - return + return successful } viewModelScope.launch { @@ -105,12 +106,18 @@ class LobbyViewModel } stopPolling() + successful = true } catch (e: Exception) { setError(e.message) + Log.e("LobbyViewModel", "Error leaving lobby: ${e.message}") + successful = false } finally { setLoading(false) } + return@launch } + Log.d("LobbyViewModel", "Leaving lobby: $lobbyCode, successful: $successful, error: ${_state.value.error}") + return successful } fun changeRole( @@ -203,8 +210,11 @@ class LobbyViewModel _state.update { response.toLobbyState() } + Log.d("LobbyViewModel", "Polling: ${_state.value}") + updateUiState(_state.value.players) } catch (e: Exception) { setError(e.message) + Log.e("LobbyViewModel", "Error polling: ${e.message}") return@launch } From 87c11d49537840af091447e8b1d74b2b816affa3 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Wed, 13 May 2026 20:04:45 +0200 Subject: [PATCH 047/121] ui_state_update v1 --- .idea/misc.xml | 1 - .../codenames/frontend/GameboardScreenTest.kt | 92 ++++++ .../frontend/network/dto/GameMessage.kt | 4 +- .../network/dto/WebSocketJoinMessage.kt | 3 + .../network/provider/RetrofitProvider.kt | 2 +- .../network/websocket/GameWebSocketHandler.kt | 12 +- .../com/codenames/frontend/ui/UiMappers.kt | 43 +++ .../frontend/ui/navigation/NavGraph.kt | 127 +++++--- .../frontend/ui/navigation/Screen.kt | 2 + .../frontend/ui/screens/GameboardScreen.kt | 288 +++++++++++++++--- .../frontend/ui/screens/JoinlobbyScreen.kt | 43 ++- .../frontend/ui/screens/LobbyScreen.kt | 176 +++++++++-- .../frontend/ui/screens/StartScreen.kt | 111 +++---- .../com/codenames/frontend/UiMappersTest.kt | 34 +++ .../frontend/viewmodel/GameViewModelTest.kt | 30 ++ 15 files changed, 789 insertions(+), 179 deletions(-) create mode 100644 app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt create mode 100644 app/src/main/java/com/codenames/frontend/ui/UiMappers.kt create mode 100644 app/src/test/java/com/codenames/frontend/UiMappersTest.kt 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/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt new file mode 100644 index 0000000..f3375a2 --- /dev/null +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -0,0 +1,92 @@ +package com.codenames.frontend + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.screens.CardType +import com.codenames.frontend.ui.screens.GameCard +import com.codenames.frontend.ui.screens.GameboardScreen +import org.junit.Rule +import org.junit.Test + +class GameboardScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun gameboardDisplaysGameStateFromParameters() { + val cards = + listOf( + GameCard("BERLIN", CardType.BLUE, revealed = false), + GameCard("ROME", CardType.RED, revealed = true), + GameCard("MOON", CardType.NEUTRAL, revealed = false), + GameCard("VIPER", CardType.ASSASSIN, revealed = false), + ) + + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_SPYMASTER, + currentHint = "EAGLE", + currentTurn = "BLUE", + remainingGuesses = 3, + currentBlueFound = 2, + currentRedFound = 1, + cards = cards, + onHintChange = {}, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("BERLIN").assertIsDisplayed() + composeRule.onNodeWithText("ROME").assertIsDisplayed() + composeRule.onNodeWithText("Turn: BLUE | Guesses: 3").assertIsDisplayed() + composeRule.onNodeWithText("2 FOUND").assertIsDisplayed() + composeRule.onNodeWithText("1 FOUND").assertIsDisplayed() + composeRule.onAllNodesWithText("Hint: EAGLE").assertCountEquals(0) + } + + @Test + fun gameboardDisplaysHintForOperative() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + onHintChange = {}, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Hint: EAGLE").assertIsDisplayed() + } + + @Test + fun gameboardDisplaysTeamChatMessages() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + chatMessages = + listOf( + ChatDomainModel( + sender = "Max", + text = "Take Berlin", + isFromMe = false, + ), + ), + onHintChange = {}, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Chat").performClick() + composeRule.onNodeWithText("Max").assertIsDisplayed() + composeRule.onNodeWithText("Take Berlin").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt index 67a4b8c..d0acd9e 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 @@ -4,11 +4,11 @@ import kotlinx.serialization.Serializable @Serializable data class GameMessage( - val winner: String = "", + val winner: String? = null, val currentTurn: String = "", val currentRedFound: Int = 0, val currentBlueFound: Int = 0, - val currentClue: String = "", + val currentClue: String? = null, val remainingGuesses: Int = 0, val cardList: List = emptyList(), ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt index 8230125..2423f49 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt @@ -1,9 +1,12 @@ package com.codenames.frontend.network.dto +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class WebSocketJoinMessage( + @SerialName("name") val username: String, + @SerialName("code") val lobbyCode: String, ) diff --git a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt index 51b2dae..c18e691 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://localhost:8080/lobby" +const val BASE_URL = "http://10.0.2.2: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 4ba6227..9b7ffc2 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 @@ -12,7 +12,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConver import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject -const val BASE_URL = "ws://localhost:8080/ws" +const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" class GameWebSocketHandler @Inject @@ -21,22 +21,20 @@ class GameWebSocketHandler ) { lateinit var session: StompSessionWithKxSerialization - // called by GameViewModel suspend fun connectStomp() { session = client.connect(BASE_URL).withJsonConversions() } suspend fun sendGuess(msg: GuessMessage) { - session.convertAndSend("/game/guess", msg, GuessMessage.serializer()) + session.convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) } - // suspend is necessary here, because api calls are suspending functions @Suppress("kotlin:S6309") - suspend fun subscribeToLobby(lobbyCode: String): Flow = session.subscribe("/game/$lobbyCode", GameMessage.serializer()) + suspend fun subscribeToLobby(lobbyCode: String): Flow = + session.subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) suspend fun sendLobbyJoinMessage(msg: WebSocketJoinMessage) { - val lobbyCode = msg.lobbyCode - session.convertAndSend("app/$lobbyCode/join", msg, WebSocketJoinMessage.serializer()) + session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } suspend fun subscribeToChat(topicPath: String): Flow = session.subscribe(topicPath, ChatMessageDto.serializer()) diff --git a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt new file mode 100644 index 0000000..f66ffb0 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt @@ -0,0 +1,43 @@ +package com.codenames.frontend.ui + +import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.screens.CardType +import com.codenames.frontend.ui.screens.GameCard + +fun CardDto.toGameCard(): GameCard = + GameCard( + word = word, + type = + when (color.uppercase()) { + "BLUE" -> CardType.BLUE + "RED" -> CardType.RED + "BLACK" -> CardType.ASSASSIN + "ASSASSIN" -> CardType.ASSASSIN + "WHITE" -> CardType.NEUTRAL + "NEUTRAL" -> CardType.NEUTRAL + else -> CardType.NEUTRAL + }, + revealed = isGuessed, + ) + +fun Player.toPlayerRole(): PlayerRoles = + when (team to role) { + Team.BLUE to Role.OPERATIVE -> PlayerRoles.BLUE_OPERATIVE + Team.BLUE to Role.SPYMASTER -> PlayerRoles.BLUE_SPYMASTER + Team.RED to Role.OPERATIVE -> PlayerRoles.RED_OPERATIVE + Team.RED to Role.SPYMASTER -> PlayerRoles.RED_SPYMASTER + else -> PlayerRoles.NONE + } + +fun PlayerRoles.toTeamAndRole(): Pair? = + when (this) { + PlayerRoles.BLUE_OPERATIVE -> Team.BLUE to Role.OPERATIVE + PlayerRoles.BLUE_SPYMASTER -> Team.BLUE to Role.SPYMASTER + PlayerRoles.RED_OPERATIVE -> Team.RED to Role.OPERATIVE + PlayerRoles.RED_SPYMASTER -> Team.RED to Role.SPYMASTER + PlayerRoles.NONE -> null + } diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt index 91f9c27..99b333e 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,11 +1,9 @@ package com.codenames.frontend.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -13,21 +11,25 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.screens.CardType -import com.codenames.frontend.ui.screens.GameCard import com.codenames.frontend.ui.screens.GameSettingsScreen -import com.codenames.frontend.ui.screens.GameTestScreen import com.codenames.frontend.ui.screens.GameboardScreen import com.codenames.frontend.ui.screens.JoinlobbyScreen import com.codenames.frontend.ui.screens.LobbyScreen +import com.codenames.frontend.ui.screens.OfflineGameStateTestScreen import com.codenames.frontend.ui.screens.SettingsScreen import com.codenames.frontend.ui.screens.StartScreen import com.codenames.frontend.ui.screens.UserNameScreen +import com.codenames.frontend.ui.toGameCard +import com.codenames.frontend.ui.toPlayerRole +import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel @Composable @Suppress("ktlint:standard:function-naming") fun NavGraph() { val navController = rememberNavController() + val lobbyViewModel: LobbyViewModel = hiltViewModel() + val gameViewModel: GameViewModel = hiltViewModel() NavHost( navController = navController, @@ -43,25 +45,57 @@ fun NavGraph() { listOf( navArgument("username") { type = NavType.StringType }, ), - ) { - StartScreen(navController) + ) { backStackEntry -> + val username = backStackEntry.arguments?.getString("username").orEmpty() + + StartScreen( + navController = navController, + username = username, + lobbyViewModel = lobbyViewModel, + ) } composable(Screen.Lobby.route) { - LobbyScreen(navController) + val lobbyState by lobbyViewModel.state.collectAsState() + val currentUsername = + lobbyState.players + .firstOrNull { it.isHost } + ?.name + .orEmpty() + + LobbyScreen( + navController = navController, + username = currentUsername, + lobbyViewModel = lobbyViewModel, + gameViewModel = gameViewModel, + ) } - composable(Screen.JoinLobby.route) { - JoinlobbyScreen(navController) + composable( + route = "${Screen.JoinLobby.route}/{username}", + arguments = + listOf( + navArgument("username") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val username = backStackEntry.arguments?.getString("username").orEmpty() + + JoinlobbyScreen( + navController = navController, + username = username, + lobbyViewModel = lobbyViewModel, + ) } composable( - route = "${Screen.Gameboard.route}/{role}", + route = "${Screen.Gameboard.route}/{username}/{role}", arguments = listOf( + navArgument("username") { type = NavType.StringType }, navArgument("role") { type = NavType.StringType }, ), ) { backStackEntry -> + val username = backStackEntry.arguments?.getString("username").orEmpty() val roleString = backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name @@ -74,7 +108,10 @@ fun NavGraph() { GameScreenWrapper( navController = navController, + username = username, userRole = passedRole, + lobbyViewModel = lobbyViewModel, + gameViewModel = gameViewModel, ) } @@ -87,7 +124,7 @@ fun NavGraph() { } composable("game_test") { - GameTestScreen() + OfflineGameStateTestScreen() } } } @@ -96,40 +133,46 @@ fun NavGraph() { @Suppress("ktlint:standard:function-naming") fun GameScreenWrapper( navController: NavHostController, + username: String, userRole: PlayerRoles, + lobbyViewModel: LobbyViewModel, + gameViewModel: GameViewModel, ) { - var currentHint by remember { mutableStateOf("Waiting for hint...") } - - val cards = - remember { - mutableStateListOf( - *List(25) { - GameCard( - word = "Word ${it + 1}", - type = - when (it) { - 0 -> CardType.ASSASSIN - in 1..8 -> CardType.BLUE - in 9..15 -> CardType.RED - else -> CardType.NEUTRAL - }, - ) - }.toTypedArray(), - ) - } + val lobbyState by lobbyViewModel.state.collectAsState() + val gameState by gameViewModel.uiState.collectAsState() + val chatState by gameViewModel.chatState.collectAsState() - fun revealCard(index: Int) { - val card = cards[index] - cards[index] = card.copy(revealed = true) - } + val currentPlayer = lobbyState.players.firstOrNull { it.name == username } + val effectiveRole = currentPlayer?.toPlayerRole() ?: userRole + val team = currentPlayer?.team + val lobbyCode = lobbyState.lobbyCode.orEmpty() + val cards = gameState.cardList.map { it.toGameCard() } GameboardScreen( - userRole = userRole, - currentHint = currentHint, - onHintChange = { currentHint = it }, + userRole = effectiveRole, + currentHint = gameState.currentClue ?: "Waiting for hint...", + currentTurn = gameState.currentTurn, + winner = gameState.winner, + remainingGuesses = gameState.remainingGuesses, + currentRedFound = gameState.currentRedFound, + currentBlueFound = gameState.currentBlueFound, cards = cards, - onReveal = { index -> - revealCard(index) + chatMessages = chatState.teamMessages, + onHintChange = { + // TODO: Send clue through GameViewModel once backend endpoint exists. + }, + onReveal = { + // TODO: Send guess through GameViewModel once backend endpoint exists. + }, + onSendChatMessage = { message -> + if (lobbyCode.isNotBlank() && team != null) { + gameViewModel.sendTeamMessage( + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = message, + ) + } }, onSettingsClick = { navController.navigate(Screen.Settings.route) diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt index 05f3c39..33464cc 100644 --- a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt @@ -16,4 +16,6 @@ sealed class Screen( object Settings : Screen("settings") object Username : Screen("username") + + object GameTest : Screen("game_test") } 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 28c3413..168b6f7 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 @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.Text @@ -39,6 +41,7 @@ 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.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -68,7 +71,6 @@ data class GameCard( fun GameTestScreen() { var currentHint by remember { mutableStateOf("Waiting for hint...") } - // will be replaced by backend call val cards = remember { mutableStateListOf( @@ -122,6 +124,13 @@ fun GameboardScreen( cards: List, onReveal: (Int) -> Unit, modifier: Modifier = Modifier, + currentTurn: String = "", + winner: String? = null, + remainingGuesses: Int = 0, + currentRedFound: Int = 0, + currentBlueFound: Int = 0, + chatMessages: List = emptyList(), + onSendChatMessage: (String) -> Unit = {}, onSettingsClick: (() -> Unit)? = null, ) { var hintInput by rememberSaveable { mutableStateOf("") } @@ -132,8 +141,6 @@ fun GameboardScreen( userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val blueLeft = cards.count { it.type == CardType.BLUE && !it.revealed } - val redLeft = cards.count { it.type == CardType.RED && !it.revealed } var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } @@ -147,6 +154,14 @@ fun GameboardScreen( .fillMaxSize() .padding(top = 72.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), ) { + GameStatusBar( + currentTurn = currentTurn, + winner = winner, + remainingGuesses = remainingGuesses, + ) + + Spacer(modifier = Modifier.height(8.dp)) + Row( modifier = Modifier @@ -156,35 +171,54 @@ fun GameboardScreen( TeamSidebar( userRole, color = Team.BLUE, - teamLeft = blueLeft, + teamFound = currentBlueFound, textColor = Color(0xFF1565C0), gradient = blueGradient, ) - GameBoardGrid( - cards, - scale, - offset, - isSpymaster, - onReveal, - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = 8.dp) - .clipToBounds() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(0.5f, 3f) - offset += pan - } - }, - ) + if (cards.isEmpty()) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .background(Color(0xFFE0D8C8), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for game state...", + color = Color(0xFF383330), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + } else { + GameBoardGrid( + cards, + scale, + offset, + isSpymaster, + onReveal, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .clipToBounds() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + offset += pan + } + }, + ) + } TeamSidebar( userRole, color = Team.RED, - teamLeft = redLeft, + teamFound = currentRedFound, textColor = Color(0xFFCF5530), gradient = redGradient, ) @@ -206,11 +240,15 @@ fun GameboardScreen( if (!isSpymaster && isChatOpen) { ChatWindow( chatInput = chatInput, + messages = chatMessages, onChatInputChange = { chatInput = it }, onSendClick = { - chatInput = "" - focusManager.clearFocus() - keyboardController?.hide() + if (chatInput.isNotBlank()) { + onSendChatMessage(chatInput) + chatInput = "" + focusManager.clearFocus() + keyboardController?.hide() + } }, modifier = Modifier @@ -240,6 +278,37 @@ fun GameboardScreen( } } +@Suppress("ktlint:standard:function-naming") +@Composable +fun GameStatusBar( + currentTurn: String, + winner: String?, + remainingGuesses: Int, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val statusText = + when { + !winner.isNullOrBlank() -> "Winner: $winner" + currentTurn.isNotBlank() -> "Turn: $currentTurn | Guesses: $remainingGuesses" + else -> "Waiting for turn..." + } + + Text( + text = statusText, + color = Color(0xFF383330), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + } +} + @Suppress("ktlint:standard:function-naming") @Composable fun ChatToggleButton( @@ -268,6 +337,7 @@ fun ChatToggleButton( @Composable fun ChatWindow( chatInput: String, + messages: List, onChatInputChange: (String) -> Unit, onSendClick: () -> Unit, modifier: Modifier = Modifier, @@ -282,6 +352,7 @@ fun ChatWindow( verticalArrangement = Arrangement.SpaceBetween, ) { ChatMessagesArea( + messages = messages, modifier = Modifier .weight(1f) @@ -339,7 +410,10 @@ fun ChatWindow( @Suppress("ktlint:standard:function-naming") @Composable -fun ChatMessagesArea(modifier: Modifier = Modifier) { +fun ChatMessagesArea( + messages: List, + modifier: Modifier = Modifier, +) { Column( modifier = modifier @@ -358,11 +432,55 @@ fun ChatMessagesArea(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(8.dp)) + if (messages.isEmpty()) { + Text( + text = "No messages yet.", + color = Color(0xFF383330), + fontSize = 14.sp, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(messages) { message -> + ChatMessageBubble(message) + } + } + } + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatMessageBubble(message: ChatDomainModel) { + val alignment = if (message.isFromMe) Alignment.End else Alignment.Start + val bubbleColor = if (message.isFromMe) Color(0xFF4CAF50) else Color(0xFFE0D8C8) + val textColor = if (message.isFromMe) Color.White else Color(0xFF383330) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = alignment, + ) { Text( - text = "Messages will appear here.", + text = message.sender, color = Color(0xFF383330), - fontSize = 14.sp, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, ) + + Box( + modifier = + Modifier + .fillMaxWidth(0.78f) + .background(bubbleColor, RoundedCornerShape(8.dp)) + .padding(8.dp), + ) { + Text( + text = message.text, + color = textColor, + fontSize = 13.sp, + ) + } } } @@ -371,7 +489,7 @@ fun ChatMessagesArea(modifier: Modifier = Modifier) { fun TeamSidebar( userRole: PlayerRoles, color: Team, - teamLeft: Int, + teamFound: Int, textColor: Color, gradient: Brush, ) { @@ -379,6 +497,7 @@ fun TeamSidebar( val operative = if (isRed) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE val spymaster = if (isRed) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER val title = if (isRed) "RED TEAM" else "BLUE TEAM" + Column( modifier = Modifier @@ -392,6 +511,7 @@ fun TeamSidebar( color = textColor, fontSize = 12.sp, ) + Spacer(modifier = Modifier.height(8.dp)) TeamRoleBox( @@ -399,7 +519,9 @@ fun TeamSidebar( gradient = gradient, isCurrentUser = userRole == operative, ) + Spacer(modifier = Modifier.height(8.dp)) + TeamRoleBox( title = "SPYMASTERS", gradient = gradient, @@ -407,11 +529,12 @@ fun TeamSidebar( ) Spacer(modifier = Modifier.height(16.dp)) + Text( - text = "$teamLeft LEFT", + text = "$teamFound FOUND", color = textColor, fontWeight = FontWeight.ExtraBold, - fontSize = 18.sp, + fontSize = 16.sp, ) } } @@ -502,7 +625,7 @@ fun TeamRoleBox( if (isCurrentUser) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "👤 You", + text = "You", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold, @@ -525,6 +648,13 @@ fun CodenamesCard( else -> Color(0xFFE0D8C8) } + val contentColor = + if (!card.revealed && !isSpymaster) { + Color(0xFF383330) + } else { + Color.White + } + AppButton( text = card.word, onClick = onClick, @@ -532,7 +662,7 @@ fun CodenamesCard( style = AppButtonStyle( containerColor = backgroundColor, - contentColor = Color.White, + contentColor = contentColor, fontSize = 10.sp, ), ) @@ -545,3 +675,91 @@ 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("BLUE") } + var remainingGuesses by rememberSaveable { mutableStateOf(3) } + var currentBlueFound by rememberSaveable { mutableStateOf(0) } + var currentRedFound by rememberSaveable { mutableStateOf(0) } + + 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 chatMessages = + listOf( + ChatDomainModel( + sender = "Max", + text = "I think BERLIN fits the hint.", + isFromMe = false, + ), + ChatDomainModel( + sender = "You", + text = "Maybe RIVER too.", + isFromMe = true, + ), + ) + + fun revealCard(index: Int) { + val card = cards[index] + + if (card.revealed) return + + cards[index] = card.copy(revealed = true) + + when (card.type) { + CardType.BLUE -> currentBlueFound++ + CardType.RED -> currentRedFound++ + CardType.NEUTRAL -> currentTurn = if (currentTurn == "BLUE") "RED" else "BLUE" + CardType.ASSASSIN -> currentTurn = "GAME OVER" + } + + if (remainingGuesses > 0) { + remainingGuesses-- + } + } + + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + currentHint = currentHint, + currentTurn = currentTurn, + remainingGuesses = remainingGuesses, + currentBlueFound = currentBlueFound, + currentRedFound = currentRedFound, + cards = cards, + chatMessages = chatMessages, + onHintChange = { currentHint = it }, + onReveal = { index -> revealCard(index) }, + onSendChatMessage = {}, + ) +} diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt index 5766599..4a7447e 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 @@ -11,7 +11,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -38,6 +41,7 @@ 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.viewmodel.LobbyViewModel internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" @@ -53,14 +57,27 @@ internal fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() @Suppress("ktlint:standard:function-naming") @Composable -fun JoinlobbyScreen(navController: NavHostController) { +fun JoinlobbyScreen( + navController: NavHostController, + username: String, + lobbyViewModel: LobbyViewModel, +) { ForceLandscape() + val lobbyState by lobbyViewModel.state.collectAsState() + var lobbyId by rememberSaveable { mutableStateOf("") } + var joinSubmitted by rememberSaveable { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + LaunchedEffect(lobbyState.lobbyCode, joinSubmitted) { + if (joinSubmitted && !lobbyState.lobbyCode.isNullOrBlank()) { + navController.navigate(Screen.Lobby.route) + } + } + val blueGradient = Brush.verticalGradient( colors = @@ -70,15 +87,15 @@ fun JoinlobbyScreen(navController: NavHostController) { ), ) - val joinEnabled = isLobbyIdValid(lobbyId) + val joinEnabled = isLobbyIdValid(lobbyId) && !lobbyState.isLoading fun submitJoin() { if (!joinEnabled) return keyboardController?.hide() focusManager.clearFocus() - - // Hier später den echten Frontend-Join-Flow anschließen. + joinSubmitted = true + lobbyViewModel.joinLobby(username, lobbyId) } Box( @@ -148,6 +165,24 @@ fun JoinlobbyScreen(navController: NavHostController) { lineHeight = 30.sp, ), ) + + if (lobbyState.isLoading) { + Text( + text = "Joining...", + color = Color(0xFF383330), + fontSize = 20.sp, + modifier = Modifier.padding(top = 12.dp), + ) + } + + lobbyState.error?.let { error -> + Text( + text = error, + color = Color(0xFFCF5530), + fontSize = 18.sp, + modifier = Modifier.padding(top = 12.dp), + ) + } } 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 313d6de..58a761f 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 @@ -13,10 +13,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -24,14 +22,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavController import androidx.navigation.NavHostController +import com.codenames.frontend.data.model.LobbyUiState +import com.codenames.frontend.data.model.Player import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.toPlayerRole +import com.codenames.frontend.ui.toTeamAndRole +import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel val blueGradient = Brush.verticalGradient( @@ -70,13 +73,61 @@ val greenGradient = ) private const val JOIN_TEAM: String = "JOIN TEAM" -private const val TEAM_JOINED: String = "👤 1 joined" @Suppress("ktlint:standard:function-naming") @Composable -fun LobbyScreen(navController: NavHostController) { - var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } +fun LobbyScreen( + navController: NavHostController, + username: String, + lobbyViewModel: LobbyViewModel, + gameViewModel: GameViewModel, +) { + val lobbyState by lobbyViewModel.state.collectAsState() + val currentPlayer = lobbyState.players.firstOrNull { it.name == username } + val currentRole = currentPlayer?.toPlayerRole() ?: PlayerRoles.NONE + + LobbyContent( + username = username, + lobbyState = lobbyState, + currentRole = currentRole, + onRoleSelect = { selectedRole -> + selectedRole.toTeamAndRole()?.let { (team, role) -> + lobbyViewModel.changeRole(username, role, team) + } + }, + onStartGame = { + val lobbyCode = lobbyState.lobbyCode.orEmpty() + val teamAndRole = currentRole.toTeamAndRole() + + if (lobbyCode.isNotBlank() && teamAndRole != null) { + val (team, role) = teamAndRole + + gameViewModel.connect( + username = username, + lobbyCode = lobbyCode, + team = team.name, + role = role.name, + ) + + navController.navigate("${Screen.Gameboard.route}/$username/${currentRole.name}") + } + }, + onSettingsClick = { + navController.navigate(Screen.Settings.route) + }, + ) +} +@Suppress("ktlint:standard:function-naming") +@Composable +fun LobbyContent( + username: String, + lobbyState: LobbyUiState, + currentRole: PlayerRoles, + onRoleSelect: (PlayerRoles) -> Unit, + onStartGame: () -> Unit, + onSettingsClick: () -> Unit, +) { Box( modifier = Modifier @@ -98,12 +149,15 @@ fun LobbyScreen(navController: NavHostController) { textColor = Color(0xFF42A5F5), title = "BLUE TEAM", currentRole = currentRole, - onRoleSelect = { currentRole = it }, + players = lobbyState.players, + onRoleSelect = onRoleSelect, ) GameSettingsColumn( + username = username, + lobbyCode = lobbyState.lobbyCode, currentRole = currentRole, - navController = navController, + onStartGame = onStartGame, ) TeamColumn( @@ -113,12 +167,25 @@ fun LobbyScreen(navController: NavHostController) { textColor = Color(0xFFDE8468), title = "RED TEAM", currentRole = currentRole, - onRoleSelect = { currentRole = it }, + players = lobbyState.players, + onRoleSelect = onRoleSelect, + ) + } + + lobbyState.error?.let { error -> + Text( + text = error, + color = Color(0xFFCF5530), + fontSize = 16.sp, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), ) } SettingsCornerButton( - onClick = { navController.navigate(Screen.Settings.route) }, + onClick = onSettingsClick, ) } } @@ -132,9 +199,11 @@ fun TeamColumn( textColor: Color, title: String, currentRole: PlayerRoles, + players: List, onRoleSelect: (PlayerRoles) -> Unit, ) { val align = if (color == Team.RED) Alignment.End else Alignment.Start + Column( modifier = modifier, verticalArrangement = Arrangement.Center, @@ -166,6 +235,7 @@ fun TeamColumn( RoleCard( role = if (color == Team.RED) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE, currentRole = currentRole, + players = players, onRoleSelect = onRoleSelect, modifier = cardModifier, title = "OPERATIVES", @@ -174,6 +244,7 @@ fun TeamColumn( RoleCard( role = if (color == Team.RED) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER, currentRole = currentRole, + players = players, onRoleSelect = onRoleSelect, modifier = cardModifier, title = "SPYMASTERS", @@ -186,23 +257,54 @@ fun TeamColumn( fun RoleCard( role: PlayerRoles, currentRole: PlayerRoles, + players: List, onRoleSelect: (PlayerRoles) -> Unit, modifier: Modifier, title: String, ) { + val playersInRole = players.filter { it.toPlayerRole() == role } + Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, ) { - Text(title, color = Color.White, fontWeight = FontWeight.Bold) + Text( + text = title, + color = Color.White, + fontWeight = FontWeight.Bold, + ) - if (currentRole == role) { - Text( - text = TEAM_JOINED, - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (playersInRole.isEmpty()) { + Text( + text = "No players", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + } else { + playersInRole.forEach { player -> + Text( + text = player.name, + color = Color.White, + fontSize = 13.sp, + fontWeight = + if (currentRole == role) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + ) + } + } } AppButton( @@ -220,9 +322,16 @@ fun RoleCard( @Suppress("ktlint:standard:function-naming") @Composable fun GameSettingsColumn( + username: String, + lobbyCode: String?, currentRole: PlayerRoles, - navController: NavController, + onStartGame: () -> Unit, ) { + val canStart = + username.isNotBlank() && + !lobbyCode.isNullOrBlank() && + currentRole != PlayerRoles.NONE + Column( modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.Center, @@ -247,13 +356,33 @@ fun GameSettingsColumn( modifier = Modifier.padding(bottom = 16.dp), ) + Text( + text = "Lobby: ${lobbyCode ?: "-"}", + color = Color.White, + fontSize = 18.sp, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Text( + text = "Player: ${username.ifBlank { "-" }}", + color = Color.White, + fontSize = 16.sp, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = "Role: ${currentRole.name}", + color = Color.White, + fontSize = 16.sp, + ) + AppButton( text = "TIMER: OFF", - onClick = { /* TODO: Timer Logik */ }, + onClick = { }, modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp), + .padding(top = 24.dp), style = AppButtonStyle( containerColor = Color(0xFF555555), @@ -265,9 +394,7 @@ fun GameSettingsColumn( AppButton( text = "START GAME", - onClick = { - navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") - }, + onClick = onStartGame, modifier = Modifier .align(Alignment.CenterHorizontally) @@ -277,6 +404,7 @@ fun GameSettingsColumn( .padding(top = 12.dp), style = AppButtonStyle( + enabled = canStart, backgroundBrush = greenGradient, fontSize = 28.sp, lineHeight = 30.sp, 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 fee12cf..8dafe7b 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -20,24 +21,30 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController -import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen -import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel @Suppress("ktlint:standard:function-naming") @Composable fun StartScreen( navController: NavHostController, - viewModel: GameViewModel = hiltViewModel(), + username: String, + lobbyViewModel: LobbyViewModel, ) { - val state by viewModel.connectionState.collectAsState() + val lobbyState by lobbyViewModel.state.collectAsState() + ForceLandscape() + LaunchedEffect(lobbyState.lobbyCode) { + if (!lobbyState.lobbyCode.isNullOrBlank()) { + navController.navigate(Screen.Lobby.route) + } + } + val greenGradient = Brush.verticalGradient( colors = @@ -76,7 +83,7 @@ fun StartScreen( AppButton( text = "Create Lobby", onClick = { - navController.navigate(Screen.Lobby.route) + lobbyViewModel.createLobby(username) }, modifier = Modifier @@ -86,6 +93,7 @@ fun StartScreen( .padding(bottom = 12.dp, end = 12.dp), style = AppButtonStyle( + enabled = !lobbyState.isLoading, backgroundBrush = greenGradient, fontSize = 26.sp, lineHeight = 30.sp, @@ -95,7 +103,7 @@ fun StartScreen( AppButton( text = "Join Lobby", onClick = { - navController.navigate(Screen.JoinLobby.route) + navController.navigate("${Screen.JoinLobby.route}/$username") }, modifier = Modifier @@ -105,70 +113,47 @@ fun StartScreen( .padding(bottom = 12.dp, start = 12.dp), style = AppButtonStyle( + enabled = !lobbyState.isLoading, backgroundBrush = blueGradient, fontSize = 26.sp, lineHeight = 30.sp, ), ) - } - - AppButton( - text = "test Mode", - onClick = { - navController.navigate("game_test") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) - AppButton( - text = "Connect to Server", - onClick = { - viewModel.connect("TestUser", "12345", "RED", "Spymaster") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) - - when (state) { - is ConnectionState.CONNECTING -> - Text( - text = "Connecting...", - color = Color.Yellow, - fontSize = 25.sp, - ) - - is ConnectionState.CONNECTED -> - Text( - text = "Connected", - color = Color.Green, - fontSize = 25.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, + ), + ) + } - is ConnectionState.Error -> { - Text("Error while connecting: ") - Text((state as ConnectionState.Error).message) - } + if (lobbyState.isLoading) { + Text( + text = "Loading...", + color = Color(0xFF383330), + fontSize = 22.sp, + ) + } - else -> {} + lobbyState.error?.let { error -> + Text( + text = error, + color = Color(0xFFCF5530), + fontSize = 18.sp, + modifier = Modifier.padding(top = 12.dp), + ) } } diff --git a/app/src/test/java/com/codenames/frontend/UiMappersTest.kt b/app/src/test/java/com/codenames/frontend/UiMappersTest.kt new file mode 100644 index 0000000..1c0e3c8 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/UiMappersTest.kt @@ -0,0 +1,34 @@ +package com.codenames.frontend + +import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.screens.CardType +import com.codenames.frontend.ui.toGameCard +import com.codenames.frontend.ui.toPlayerRole +import org.junit.Assert.assertEquals +import org.junit.Test + +class UiMappersTest { + @Test + fun cardDtoMapsBackendColorsToGameCards() { + assertEquals(CardType.BLUE, CardDto("A", "BLUE", false).toGameCard().type) + assertEquals(CardType.RED, CardDto("A", "RED", false).toGameCard().type) + assertEquals(CardType.ASSASSIN, CardDto("A", "BLACK", false).toGameCard().type) + assertEquals(CardType.NEUTRAL, CardDto("A", "HIDDEN", false).toGameCard().type) + } + + @Test + fun playerMapsToPlayerRole() { + val player = + Player( + name = "Max", + role = Role.SPYMASTER, + team = Team.BLUE, + ) + + assertEquals(PlayerRoles.BLUE_SPYMASTER, player.toPlayerRole()) + } +} diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt index 3a04836..8c9c270 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -4,6 +4,7 @@ import com.codenames.frontend.data.model.ChatDomainModel 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.network.dto.CardDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler @@ -224,4 +225,33 @@ class GameViewModelTest { val currentMessageList = viewModel.chatState.value.operativeMessages assertTrue(currentMessageList.isEmpty()) } + + @Test + fun handleMessage_updatesGameState() = + runTest { + val message = + GameMessage( + winner = null, + currentTurn = "BLUE", + currentRedFound = 1, + currentBlueFound = 2, + currentClue = "EAGLE", + remainingGuesses = 3, + cardList = + listOf( + CardDto("BERLIN", "BLUE", false), + CardDto("ROME", "RED", true), + ), + ) + + viewModel.handleMessage(message) + + val state = viewModel.uiState.value + + assertEquals("BLUE", state.currentTurn) + assertEquals("EAGLE", state.currentClue) + assertEquals(3, state.remainingGuesses) + assertEquals(2, state.cardList.size) + assertEquals("BERLIN", state.cardList[0].word) + } } From a77fda81b99ac4e6f2d72ff3f056b3127c2aaf31 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 20:59:19 +0200 Subject: [PATCH 048/121] commit for device change; changed order of parameters of leave flow to be correct --- .../com/codenames/frontend/data/repository/LobbyRepository.kt | 4 ++-- .../java/com/codenames/frontend/ui/screens/LobbyScreen.kt | 1 + .../java/com/codenames/frontend/viewmodel/LobbyViewModel.kt | 1 + 3 files changed, 4 insertions(+), 2 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 30c64cf..8e8c3a2 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 @@ -17,9 +17,9 @@ class LobbyRepository } suspend fun leaveLobby( - username: String, lobbyCode: String, - ): LobbyResponse = api.leaveLobby(username, lobbyCode) + username: String, + ): LobbyResponse = api.leaveLobby(lobbyCode, username) suspend fun joinLobby( username: String, 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 3200bb0..685ede8 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 @@ -46,6 +46,7 @@ import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel +import kotlinx.coroutines.launch private const val JOIN_TEAM: String = "JOIN TEAM" private const val TEAM_JOINED: String = "👤 1 joined" 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 009d722..70cb18b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -226,6 +226,7 @@ class LobbyViewModel private fun stopPolling() { pollingJob?.cancel() pollingJob = null + Log.d("LobbyViewModel", "Polling stopped") } private fun setError(msg: String?) { From 046400ba355775e8c4140f0f06ede6945ca54170 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 22:01:40 +0200 Subject: [PATCH 049/121] fix leaving lobby logic --- .../frontend/ui/screens/LobbyScreen.kt | 8 +++--- .../frontend/viewmodel/LobbyViewModel.kt | 25 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 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 685ede8..4aae320 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 @@ -281,10 +281,12 @@ fun GameSettingsColumn( AppButton( text = "LEAVE LOBBY", onClick = { - val successful = viewModel.leaveLobby(username = usernameState.username) - if(successful) navController.navigate(Screen.Start.route) { - popUpTo(Screen.Start.route) { inclusive = true } + val onResult = { successful : Boolean -> + if(successful) navController.navigate(Screen.Start.route) { + popUpTo(Screen.Start.route) { inclusive = true } + } } + viewModel.leaveLobby(username = usernameState.username, 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 70cb18b..ce0bc50 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -87,20 +87,21 @@ class LobbyViewModel } } - fun leaveLobby(username: String) : Boolean{ + fun leaveLobby(username: String, onResult: (Boolean) -> Unit){ val lobbyCode = _state.value.lobbyCode var successful = false if (lobbyCode.isNullOrBlank()) { setError("Not in a lobby, leaving not possible") - return successful + onResult(successful) + return } viewModelScope.launch { setLoading(true) try { - val response = repository.leaveLobby(username, lobbyCode) + val response = repository.leaveLobby(lobbyCode, username) _state.update { response.toLobbyState() } @@ -113,11 +114,10 @@ class LobbyViewModel successful = false } finally { setLoading(false) + onResult(successful) + if (successful) cleanup() } - return@launch } - Log.d("LobbyViewModel", "Leaving lobby: $lobbyCode, successful: $successful, error: ${_state.value.error}") - return successful } fun changeRole( @@ -174,6 +174,19 @@ class LobbyViewModel } } + private fun cleanup() { + _state.update { + it.copy( + lobbyCode = null, + players = emptyList(), + blueOperatives = emptyList(), + blueSpymasters = emptyList(), + redOperatives = emptyList(), + redSpymasters = emptyList(), + ) + } + } + private fun updateUiState(players: List) { _state.update { it.copy( From 5f2682c0e61dc2c9269a21cf0b3e7fde858faa96 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 22:16:00 +0200 Subject: [PATCH 050/121] fix: join works now, added viewmodel owner to joinLobbyScreen --- .../data/repository/LobbyRepository.kt | 4 +- .../ui/buttons/SettingsCornerButton.kt | 10 +- .../frontend/ui/navigation/NavGraph.kt | 9 +- .../frontend/ui/screens/JoinLobbyScreen.kt | 7 +- .../frontend/ui/screens/LobbyScreen.kt | 44 ++++----- .../frontend/ui/screens/StartScreen.kt | 2 - .../frontend/ui/screens/UserNameScreen.kt | 2 - .../frontend/viewmodel/LobbyViewModel.kt | 96 ++++++++++--------- .../frontend/viewmodel/SessionViewModel.kt | 14 +-- 9 files changed, 97 insertions(+), 91 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 8e8c3a2..c938df0 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 @@ -12,9 +12,7 @@ class LobbyRepository constructor( private val api: LobbyApi, ) { - suspend fun createLobby(username: String): LobbyResponse{ - return api.createLobby(username) - } + suspend fun createLobby(username: String): LobbyResponse = api.createLobby(username) suspend fun leaveLobby( lobbyCode: String, 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 00e00a9..843f56d 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 @@ -1,6 +1,5 @@ package com.codenames.frontend.ui.buttons -import android.widget.Button import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize @@ -34,10 +33,11 @@ fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { androidx.compose.material3.IconButton( onClick = onClick, modifier = Modifier.fillMaxSize(), - colors = androidx.compose.material3.IconButtonDefaults.iconButtonColors( - containerColor = Color.Transparent, - contentColor = Color.White, - ), + colors = + androidx.compose.material3.IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = Color.White, + ), ) { Icon( imageVector = androidx.compose.material.icons.Icons.Default.Settings, 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 eb59f3d..e64cae6 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 @@ -20,7 +20,10 @@ import com.codenames.frontend.viewmodel.SessionViewModel @Composable @Suppress("ktlint:standard:function-naming") -fun NavGraph(viewModel: LobbyViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel()) { +fun NavGraph( + viewModel: LobbyViewModel = hiltViewModel(), + sessionViewModel: SessionViewModel = hiltViewModel(), +) { val navController = rememberNavController() val usernameState by sessionViewModel.username.collectAsState() @@ -50,7 +53,7 @@ fun NavGraph(viewModel: LobbyViewModel = hiltViewModel(), sessionViewModel: Sess } composable( - route = Screen.Gameboard.route + route = Screen.Gameboard.route, ) { val currentRole = viewModel.getRoleForUser(usernameState.username) @@ -72,4 +75,4 @@ fun NavGraph(viewModel: LobbyViewModel = hiltViewModel(), sessionViewModel: Sess GameTestScreen() } } -} \ No newline at end of file +} 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 8c7cd2e..bb3b3c7 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 @@ -30,7 +30,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -57,14 +56,14 @@ private fun sanitizeLobbyIdInput(input: String): String = fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() && - lobbyId.length == LOBBY_ID_LENGTH && - lobbyId.matches("^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$".toRegex()) + lobbyId.length == LOBBY_ID_LENGTH && + lobbyId.matches("^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$".toRegex()) @Suppress("ktlint:standard:function-naming") @Composable fun JoinLobbyScreen( navController: NavHostController, - viewModel: LobbyViewModel = hiltViewModel(), + viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), ) { ForceLandscape() 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 4aae320..14c0014 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 @@ -24,15 +24,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.NavHostController import com.codenames.frontend.data.model.LobbyUiState -import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -46,14 +43,16 @@ import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel -import kotlinx.coroutines.launch private const val JOIN_TEAM: String = "JOIN TEAM" -private const val TEAM_JOINED: String = "👤 1 joined" @Suppress("ktlint:standard:function-naming") @Composable -fun LobbyScreen(navController: NavHostController, viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph"))) { +fun LobbyScreen( + navController: NavHostController, + viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), + sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), +) { val usernameState by sessionViewModel.username.collectAsState() val lobbyUiState by viewModel.state.collectAsState() @@ -82,11 +81,12 @@ fun LobbyScreen(navController: NavHostController, viewModel: LobbyViewModel = hi ) GameSettingsColumn( - modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxHeight(), + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxHeight(), navController = navController, - lobbyCode = lobbyUiState.lobbyCode ?: "" + lobbyCode = lobbyUiState.lobbyCode ?: "", ) TeamColumn( @@ -119,8 +119,9 @@ fun TeamColumn( ) { 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, ) { @@ -152,7 +153,7 @@ fun TeamColumn( onRoleSelect = onRoleSelect, modifier = cardModifier, title = "OPERATIVES", - players = if(color == Team.RED) lobbyUiState.redOperatives else lobbyUiState.blueOperatives, + players = if (color == Team.RED) lobbyUiState.redOperatives else lobbyUiState.blueOperatives, ) RoleCard( @@ -160,7 +161,7 @@ fun TeamColumn( onRoleSelect = onRoleSelect, modifier = cardModifier, title = "SPYMASTERS", - players = if(color == Team.RED) lobbyUiState.redSpymasters else lobbyUiState.blueSpymasters, + players = if (color == Team.RED) lobbyUiState.redSpymasters else lobbyUiState.blueSpymasters, ) } } @@ -220,7 +221,7 @@ fun GameSettingsColumn( Modifier .align(Alignment.CenterHorizontally) .padding(top = 8.dp), - ) + ) Spacer(modifier = Modifier.weight(1f)) @@ -281,9 +282,11 @@ fun GameSettingsColumn( AppButton( text = "LEAVE LOBBY", onClick = { - val onResult = { successful : Boolean -> - if(successful) navController.navigate(Screen.Start.route) { - popUpTo(Screen.Start.route) { inclusive = true } + val onResult = { successful: Boolean -> + if (successful) { + navController.navigate(Screen.Start.route) { + popUpTo(Screen.Start.route) { inclusive = true } + } } } viewModel.leaveLobby(username = usernameState.username, onResult = onResult) @@ -301,10 +304,7 @@ fun GameSettingsColumn( contentColor = Color.Black, type = AppButtonType.SECONDARY, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), - ) - + ), ) } } - - diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt index 050bfaa..8906c41 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 @@ -1,6 +1,5 @@ package com.codenames.frontend.ui.screens -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,7 +21,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle 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 2d55411..badeb5c 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 @@ -1,6 +1,5 @@ package com.codenames.frontend.ui.screens -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,7 +24,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle 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 ce0bc50..796d6d2 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -78,7 +78,10 @@ class LobbyViewModel _state.update { response.toLobbyState() } + updateUiState(_state.value.players) startPolling(response.lobbyCode) + Log.d("LobbyViewModel", "Joined lobby: ${response.lobbyCode}") + Log.d("LobbyViewModel", "UI State: ${_state.value}") } catch (e: Exception) { setError(e.message) } finally { @@ -87,7 +90,10 @@ class LobbyViewModel } } - fun leaveLobby(username: String, onResult: (Boolean) -> Unit){ + fun leaveLobby( + username: String, + onResult: (Boolean) -> Unit, + ) { val lobbyCode = _state.value.lobbyCode var successful = false @@ -123,7 +129,7 @@ class LobbyViewModel fun changeRole( role: Role, team: Team, - username: String + username: String, ) { Log.d("LobbyViewModel", "Changing role to: $role, team: $team, username: $username") val lobbyCode = _state.value.lobbyCode @@ -155,8 +161,11 @@ class LobbyViewModel } } - fun changeRole(role: PlayerRoles, username: String) { - when(role) { + fun changeRole( + role: PlayerRoles, + username: String, + ) { + when (role) { PlayerRoles.BLUE_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.BLUE, username = username) PlayerRoles.RED_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.RED, username = username) PlayerRoles.BLUE_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.BLUE, username = username) @@ -165,51 +174,50 @@ class LobbyViewModel } } - fun getRoleForUser(username: String) : PlayerRoles { - val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return PlayerRoles.NONE - return when(player.role) { - Role.OPERATIVE -> if(player.team == Team.BLUE) PlayerRoles.BLUE_OPERATIVE else PlayerRoles.RED_OPERATIVE - Role.SPYMASTER -> if(player.team == Team.BLUE) PlayerRoles.BLUE_SPYMASTER else PlayerRoles.RED_SPYMASTER - null -> PlayerRoles.NONE + fun getRoleForUser(username: String): PlayerRoles { + val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return PlayerRoles.NONE + return when (player.role) { + Role.OPERATIVE -> if (player.team == Team.BLUE) PlayerRoles.BLUE_OPERATIVE else PlayerRoles.RED_OPERATIVE + Role.SPYMASTER -> if (player.team == Team.BLUE) PlayerRoles.BLUE_SPYMASTER else PlayerRoles.RED_SPYMASTER + null -> PlayerRoles.NONE + } } - } - private fun cleanup() { - _state.update { - it.copy( - lobbyCode = null, - players = emptyList(), - blueOperatives = emptyList(), - blueSpymasters = emptyList(), - redOperatives = emptyList(), - redSpymasters = emptyList(), - ) + private fun cleanup() { + _state.update { + it.copy( + lobbyCode = null, + players = emptyList(), + blueOperatives = emptyList(), + blueSpymasters = emptyList(), + redOperatives = emptyList(), + redSpymasters = emptyList(), + ) + } } - } - private fun updateUiState(players: List) { - _state.update { - it.copy( - blueOperatives = players - .filter { p -> p.team == Team.BLUE && p.role == Role.OPERATIVE } - .map { it.name }, - - blueSpymasters = players - .filter { p -> p.team == Team.BLUE && p.role == Role.SPYMASTER } - .map { it.name }, - - redOperatives = players - .filter { p -> p.team == Team.RED && p.role == Role.OPERATIVE } - .map { it.name }, - - redSpymasters = players - .filter { p -> p.team == Team.RED && p.role == Role.SPYMASTER } - .map { it.name }, - ) + private fun updateUiState(players: List) { + _state.update { + it.copy( + blueOperatives = + players + .filter { p -> p.team == Team.BLUE && p.role == Role.OPERATIVE } + .map { p -> p.name }, + blueSpymasters = + players + .filter { p -> p.team == Team.BLUE && p.role == Role.SPYMASTER } + .map { p -> p.name }, + redOperatives = + players + .filter { p -> p.team == Team.RED && p.role == Role.OPERATIVE } + .map { p -> p.name }, + redSpymasters = + players + .filter { p -> p.team == Team.RED && p.role == Role.SPYMASTER } + .map { p -> p.name }, + ) + } } - } - - private fun startPolling(lobbyCode: String) { if (pollingJob != null) return 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 95a546d..8a9d666 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -9,11 +9,13 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel -class SessionViewModel @Inject constructor(): ViewModel() { - private val _username = MutableStateFlow(SessionState("")) - val username: StateFlow = _username +class SessionViewModel + @Inject + constructor() : ViewModel() { + private val _username = MutableStateFlow(SessionState("")) + val username: StateFlow = _username - fun setUsername(username: String) { - _username.update { SessionState(username) } + fun setUsername(username: String) { + _username.update { SessionState(username) } + } } -} From 425bab60033010b9b198d3c1b16d1d76db66185e Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 22:44:24 +0200 Subject: [PATCH 051/121] test: add tests for viewmodels --- .../frontend/viewmodel/LobbyViewModel.kt | 16 ------- .../frontend/viewmodel/LobbyViewModelTest.kt | 46 +++++++++++++++---- .../viewmodel/SessionViewModelTest.kt | 34 ++++++++++++++ 3 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt 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 796d6d2..e6a1f32 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -1,6 +1,5 @@ package com.codenames.frontend.viewmodel -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.LobbyUiState @@ -48,11 +47,8 @@ class LobbyViewModel response.toLobbyState() } startPolling(response.lobbyCode) - Log.d("LobbyViewModel", "Lobby created: ${response.lobbyCode}") - Log.d("LobbyViewModel", "UI State: ${_state.value}") } catch (e: Exception) { setError(e.message) - Log.e("LobbyViewModel", "Error creating lobby: ${e.message}") } finally { setLoading(false) } @@ -80,8 +76,6 @@ class LobbyViewModel } updateUiState(_state.value.players) startPolling(response.lobbyCode) - Log.d("LobbyViewModel", "Joined lobby: ${response.lobbyCode}") - Log.d("LobbyViewModel", "UI State: ${_state.value}") } catch (e: Exception) { setError(e.message) } finally { @@ -116,7 +110,6 @@ class LobbyViewModel successful = true } catch (e: Exception) { setError(e.message) - Log.e("LobbyViewModel", "Error leaving lobby: ${e.message}") successful = false } finally { setLoading(false) @@ -131,11 +124,9 @@ class LobbyViewModel team: Team, username: String, ) { - Log.d("LobbyViewModel", "Changing role to: $role, team: $team, username: $username") val lobbyCode = _state.value.lobbyCode if (lobbyCode.isNullOrBlank()) { setError("Not in a Lobby") - Log.d("LobbyViewModel", "Not in a lobby") return } @@ -149,12 +140,8 @@ class LobbyViewModel response.toLobbyState() } updateUiState(_state.value.players) - Log.d("LobbyViewModel", "Role changed: $role") - Log.d("LobbyViewModel", "Team changed: $team") - Log.d("LobbyViewModel", "UI State: ${_state.value}") } catch (e: Exception) { setError(e.message) - Log.e("LobbyViewModel", "Error changing role: ${e.message}") } finally { setLoading(false) } @@ -231,11 +218,9 @@ class LobbyViewModel _state.update { response.toLobbyState() } - Log.d("LobbyViewModel", "Polling: ${_state.value}") updateUiState(_state.value.players) } catch (e: Exception) { setError(e.message) - Log.e("LobbyViewModel", "Error polling: ${e.message}") return@launch } @@ -247,7 +232,6 @@ class LobbyViewModel private fun stopPolling() { pollingJob?.cancel() pollingJob = null - Log.d("LobbyViewModel", "Polling stopped") } private fun setError(msg: String?) { diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt index b812802..a11ba2a 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -5,9 +5,15 @@ import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.LobbyRepository import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto +import com.codenames.frontend.ui.roles.PlayerRoles +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -20,6 +26,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -160,13 +167,13 @@ class LobbyViewModelTest { coEvery { repository.joinLobby("User", "1234") } returns response coEvery { repository.getLobbyInfo(any()) } returns response - coEvery { repository.leaveLobby("User", "1234") } returns response2 + coEvery { repository.leaveLobby("1234", "User") } returns response2 val viewModel = LobbyViewModel(repository) viewModel.joinLobby("User", "1234") advanceTimeBy(2000) - viewModel.leaveLobby("User") + viewModel.leaveLobby("User", onResult = {}) advanceTimeBy(2000) @@ -174,7 +181,7 @@ class LobbyViewModelTest { val state = viewModel.state.value - assertEquals("", state.lobbyCode) + assertEquals(null, state.lobbyCode) assertFalse(state.isLoading) assertNull(state.error) } @@ -199,7 +206,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.leaveLobby("User") + viewModel.leaveLobby("User", onResult = {}) advanceTimeBy(2000) @@ -241,7 +248,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.changeRole("User", newRole, newTeam) + viewModel.changeRole(newRole, newTeam, "User") viewModel.stopPollingForTest() advanceTimeBy(2000) @@ -282,7 +289,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.changeRole("User", Role.OPERATIVE, Team.RED) + viewModel.changeRole(Role.OPERATIVE, Team.RED,"User") advanceTimeBy(2000) @@ -482,7 +489,7 @@ class LobbyViewModelTest { val viewModel = LobbyViewModel(repository) - viewModel.leaveLobby("User") + viewModel.leaveLobby("User", onResult = {}) advanceUntilIdle() @@ -500,7 +507,7 @@ class LobbyViewModelTest { val viewModel = LobbyViewModel(repository) - viewModel.changeRole("User", Role.OPERATIVE, Team.RED) + viewModel.changeRole( Role.OPERATIVE, Team.RED,"User") advanceUntilIdle() @@ -510,4 +517,27 @@ class LobbyViewModelTest { assertFalse(state.isLoading) assertEquals("Not in a Lobby", state.error) } + + @Test + fun changeRole_DelegatesCorrectly1() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository), recordPrivateCalls = true) + viewModel.changeRole(PlayerRoles.BLUE_SPYMASTER, "Alice") + + verify { + viewModel.changeRole(Role.SPYMASTER, Team.BLUE, "Alice") + } + } + + @Test + fun changeRole_DelegatesCorrectly2() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository), recordPrivateCalls = true) + + viewModel.changeRole(PlayerRoles.RED_OPERATIVE, "Bob") + + verify { + viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob") + } + } } diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt new file mode 100644 index 0000000..f3dc799 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -0,0 +1,34 @@ +package com.codenames.frontend.viewmodel + +import com.codenames.frontend.data.model.SessionState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionViewModelTest { + + private lateinit var viewModel: SessionViewModel + + @Before + fun setup() { + viewModel = SessionViewModel() + } + + @Test + fun `initial username is empty`() { + val result = viewModel.username.value + + assertEquals(SessionState(""), result) + } + + @Test + fun `setUsername updates username state`() { + viewModel.setUsername("Max") + + val result = viewModel.username.value + + assertEquals(SessionState("Max"), result) + } +} \ No newline at end of file From 718677802ac6b551a55103683ab6095241cf373c Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 13 May 2026 22:48:24 +0200 Subject: [PATCH 052/121] format: formatted tests --- .../codenames/frontend/viewmodel/LobbyViewModelTest.kt | 8 ++------ .../codenames/frontend/viewmodel/SessionViewModelTest.kt | 3 +-- 2 files changed, 3 insertions(+), 8 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 a11ba2a..3605bb0 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -6,11 +6,8 @@ import com.codenames.frontend.data.repository.LobbyRepository import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto import com.codenames.frontend.ui.roles.PlayerRoles -import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every -import io.mockk.just import io.mockk.mockk import io.mockk.spyk import io.mockk.verify @@ -26,7 +23,6 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -289,7 +285,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.changeRole(Role.OPERATIVE, Team.RED,"User") + viewModel.changeRole(Role.OPERATIVE, Team.RED, "User") advanceTimeBy(2000) @@ -507,7 +503,7 @@ class LobbyViewModelTest { val viewModel = LobbyViewModel(repository) - viewModel.changeRole( Role.OPERATIVE, Team.RED,"User") + viewModel.changeRole(Role.OPERATIVE, Team.RED, "User") 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 f3dc799..7b16c58 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -8,7 +8,6 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class SessionViewModelTest { - private lateinit var viewModel: SessionViewModel @Before @@ -31,4 +30,4 @@ class SessionViewModelTest { assertEquals(SessionState("Max"), result) } -} \ No newline at end of file +} From 7229a8e1da30b27d52f5081005d5cdf4c36bfc3d Mon Sep 17 00:00:00 2001 From: ad-devel Date: Thu, 14 May 2026 09:04:05 +0200 Subject: [PATCH 053/121] Fixed GameWebSocketHandlerTest issues --- .../network/websocket/GameWebSocketHandlerTest.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 971a5ab..b02ad8b 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 @@ -68,7 +68,7 @@ class GameWebSocketHandlerTest { wsClient.sendGuess(msg) coVerify { - session.convertAndSend("/game/guess", msg, GuessMessage.serializer()) + session.convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) } } @@ -79,18 +79,17 @@ class GameWebSocketHandlerTest { val client = mockk() coEvery { - session.subscribe("/game/ABCDE") + session.subscribe("/topic/game/ABCDE") } returns emptyFlow() val wsClient = GameWebSocketHandler(client) wsClient.session = session - wsClient.subscribeToLobby("ABCDE") // warnings can be ignored, only subscribe is tested here, not the return value + wsClient.subscribeToLobby("ABCDE") - // only check destination, ignore headers and payload coVerify { session.subscribe( - match { it.destination == "/game/ABCDE" }, + match { it.destination == "/topic/game/ABCDE" }, GameMessage.serializer(), ) } @@ -110,7 +109,7 @@ class GameWebSocketHandlerTest { wsClient.sendLobbyJoinMessage(msg) coVerify { - session.convertAndSend("app/1234/join", msg, WebSocketJoinMessage.serializer()) + session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } } From d027654592ba790c3404e52d8c09a0b10d1b9bd8 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Fri, 15 May 2026 09:06:53 +0200 Subject: [PATCH 054/121] Fix sonar issues --- .../codenames/frontend/GameboardScreenTest.kt | 44 ++++++++------ .../network/provider/RetrofitProvider.kt | 2 +- .../network/websocket/GameWebSocketHandler.kt | 4 +- .../frontend/ui/navigation/NavGraph.kt | 20 ++++--- .../frontend/ui/screens/GameboardScreen.kt | 60 +++++++++++++------ .../frontend/viewmodel/GameViewModel.kt | 2 +- .../websocket/GameWebSocketHandlerTest.kt | 4 +- .../frontend/viewmodel/GameViewModelTest.kt | 6 +- 8 files changed, 89 insertions(+), 53 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index f3375a2..c32258f 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -10,6 +10,7 @@ import com.codenames.frontend.data.model.ChatDomainModel import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.screens.CardType import com.codenames.frontend.ui.screens.GameCard +import com.codenames.frontend.ui.screens.GameState import com.codenames.frontend.ui.screens.GameboardScreen import org.junit.Rule import org.junit.Test @@ -31,12 +32,15 @@ class GameboardScreenTest { composeRule.setContent { GameboardScreen( userRole = PlayerRoles.BLUE_SPYMASTER, - currentHint = "EAGLE", - currentTurn = "BLUE", - remainingGuesses = 3, - currentBlueFound = 2, - currentRedFound = 1, - cards = cards, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = "BLUE", + remainingGuesses = 3, + currentBlueFound = 2, + currentRedFound = 1, + cards = cards, + ), onHintChange = {}, onReveal = {}, ) @@ -55,8 +59,11 @@ class GameboardScreenTest { composeRule.setContent { GameboardScreen( userRole = PlayerRoles.BLUE_OPERATIVE, - currentHint = "EAGLE", - cards = listOf(GameCard("BERLIN", CardType.BLUE)), + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), onHintChange = {}, onReveal = {}, ) @@ -70,15 +77,18 @@ class GameboardScreenTest { composeRule.setContent { GameboardScreen( userRole = PlayerRoles.BLUE_OPERATIVE, - currentHint = "EAGLE", - cards = listOf(GameCard("BERLIN", CardType.BLUE)), - chatMessages = - listOf( - ChatDomainModel( - sender = "Max", - text = "Take Berlin", - isFromMe = false, - ), + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + chatMessages = + listOf( + ChatDomainModel( + sender = "Max", + text = "Take Berlin", + isFromMe = false, + ), + ), ), onHintChange = {}, onReveal = {}, 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 c18e691..3d1b1f5 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://10.0.2.2:8080/" +const val BASE_URL = "http://localhost:8080/" @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt index 9b7ffc2..4026e6b 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 @@ -12,7 +12,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConver import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject -const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" +const val BASE_URL = "ws://localhost:8080/ws-fallback" class GameWebSocketHandler @Inject @@ -33,7 +33,7 @@ class GameWebSocketHandler suspend fun subscribeToLobby(lobbyCode: String): Flow = session.subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) - suspend fun sendLobbyJoinMessage(msg: WebSocketJoinMessage) { + suspend fun registerWebSocketSession(msg: WebSocketJoinMessage) { session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } 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 99b333e..6d9735f 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 @@ -12,6 +12,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.screens.GameSettingsScreen +import com.codenames.frontend.ui.screens.GameState import com.codenames.frontend.ui.screens.GameboardScreen import com.codenames.frontend.ui.screens.JoinlobbyScreen import com.codenames.frontend.ui.screens.LobbyScreen @@ -150,14 +151,17 @@ fun GameScreenWrapper( GameboardScreen( userRole = effectiveRole, - currentHint = gameState.currentClue ?: "Waiting for hint...", - currentTurn = gameState.currentTurn, - winner = gameState.winner, - remainingGuesses = gameState.remainingGuesses, - currentRedFound = gameState.currentRedFound, - currentBlueFound = gameState.currentBlueFound, - cards = cards, - chatMessages = chatState.teamMessages, + gameState = + GameState( + currentHint = gameState.currentClue ?: "Waiting for hint...", + currentTurn = gameState.currentTurn, + winner = gameState.winner, + remainingGuesses = gameState.remainingGuesses, + currentRedFound = gameState.currentRedFound, + currentBlueFound = gameState.currentBlueFound, + cards = cards, + chatMessages = chatState.teamMessages, + ), onHintChange = { // TODO: Send clue through GameViewModel once backend endpoint exists. }, 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 168b6f7..93d2351 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 @@ -66,6 +66,17 @@ data class GameCard( val revealed: Boolean = false, ) +data class GameState( + val currentHint: String, + val cards: List, + val currentTurn: String = "", + val winner: String? = null, + val remainingGuesses: Int = 0, + val currentRedFound: Int = 0, + val currentBlueFound: Int = 0, + val chatMessages: List = emptyList(), +) + @Suppress("ktlint:standard:function-naming") @Composable fun GameTestScreen() { @@ -97,18 +108,24 @@ fun GameTestScreen() { Row(modifier = Modifier.fillMaxSize()) { GameboardScreen( userRole = PlayerRoles.BLUE_SPYMASTER, - currentHint = currentHint, + gameState = + GameState( + currentHint = currentHint, + cards = cards, + ), onHintChange = { currentHint = it }, - cards = cards, onReveal = {}, modifier = Modifier.weight(1f), ) GameboardScreen( userRole = PlayerRoles.BLUE_OPERATIVE, - currentHint = currentHint, + gameState = + GameState( + currentHint = currentHint, + cards = cards, + ), onHintChange = {}, - cards = cards, onReveal = { index -> revealCard(index) }, modifier = Modifier.weight(1f), ) @@ -119,20 +136,22 @@ fun GameTestScreen() { @Composable fun GameboardScreen( userRole: PlayerRoles, - currentHint: String, + gameState: GameState, onHintChange: (String) -> Unit, - cards: List, onReveal: (Int) -> Unit, modifier: Modifier = Modifier, - currentTurn: String = "", - winner: String? = null, - remainingGuesses: Int = 0, - currentRedFound: Int = 0, - currentBlueFound: Int = 0, - chatMessages: List = emptyList(), onSendChatMessage: (String) -> Unit = {}, onSettingsClick: (() -> Unit)? = null, ) { + val currentHint = gameState.currentHint + val cards = gameState.cards + val currentTurn = gameState.currentTurn + val winner = gameState.winner + val remainingGuesses = gameState.remainingGuesses + val currentRedFound = gameState.currentRedFound + val currentBlueFound = gameState.currentBlueFound + val chatMessages = gameState.chatMessages + var hintInput by rememberSaveable { mutableStateOf("") } var chatInput by rememberSaveable { mutableStateOf("") } var isChatOpen by rememberSaveable { mutableStateOf(false) } @@ -751,13 +770,16 @@ fun OfflineGameStateTestScreen() { GameboardScreen( userRole = PlayerRoles.BLUE_OPERATIVE, - currentHint = currentHint, - currentTurn = currentTurn, - remainingGuesses = remainingGuesses, - currentBlueFound = currentBlueFound, - currentRedFound = currentRedFound, - cards = cards, - chatMessages = chatMessages, + gameState = + GameState( + currentHint = currentHint, + currentTurn = currentTurn, + remainingGuesses = remainingGuesses, + currentBlueFound = currentBlueFound, + currentRedFound = currentRedFound, + cards = cards, + chatMessages = chatMessages, + ), onHintChange = { currentHint = it }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, 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 6d6651c..05269db 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -88,7 +88,7 @@ class GameViewModel } } - client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) + client.registerWebSocketSession(WebSocketJoinMessage(username, lobbyCode)) } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } 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 b02ad8b..473a6ea 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 @@ -96,7 +96,7 @@ class GameWebSocketHandlerTest { } @Test - fun testSendJoinMessage_sendsMessage(): Unit = + fun testRegisterWebSocketSessionSendsMessage() = runTest { val session = mockk(relaxed = true) val client = mockk() @@ -106,7 +106,7 @@ class GameWebSocketHandlerTest { val msg = WebSocketJoinMessage("name", "1234") - wsClient.sendLobbyJoinMessage(msg) + wsClient.registerWebSocketSession(msg) coVerify { session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) 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 8c9c270..313caa7 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -105,19 +105,19 @@ class GameViewModelTest { } @Test - fun connect_shouldSendJoinMessage() = + fun connect_shouldRegisterWebSocketSession() = runTest { val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - coEvery { client.sendLobbyJoinMessage(any()) } just Runs + coEvery { client.registerWebSocketSession(any()) } just Runs viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() - coVerify { client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) } + coVerify { client.registerWebSocketSession(WebSocketJoinMessage(username, lobbyCode)) } } @Test From 795624df51d89078d49da06ccca661fbec3d73fa Mon Sep 17 00:00:00 2001 From: 5eli Date: Fri, 15 May 2026 12:49:57 +0200 Subject: [PATCH 055/121] updated chat UI with different tabs for different chat-types --- .idea/misc.xml | 1 + .../frontend/ui/screens/GameboardScreen.kt | 66 ++++++++++++++----- 2 files changed, 52 insertions(+), 15 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/ui/screens/GameboardScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt index 93d2351..7c590ce 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.Text @@ -52,6 +53,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.buttons.AppButtonType enum class CardType { BLUE, @@ -155,6 +157,7 @@ fun GameboardScreen( var hintInput by rememberSaveable { mutableStateOf("") } var chatInput by rememberSaveable { mutableStateOf("") } var isChatOpen by rememberSaveable { mutableStateOf(false) } + var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER @@ -260,8 +263,10 @@ fun GameboardScreen( ChatWindow( chatInput = chatInput, messages = chatMessages, + selectedTab = selectedChatTab, + onTabSelected = { selectedChatTab = it }, onChatInputChange = { chatInput = it }, - onSendClick = { + onSendClick = { tab -> if (chatInput.isNotBlank()) { onSendChatMessage(chatInput) chatInput = "" @@ -269,12 +274,11 @@ fun GameboardScreen( keyboardController?.hide() } }, - modifier = - Modifier - .align(Alignment.Center) - .padding(end = 24.dp, bottom = 96.dp) - .width(420.dp) - .fillMaxHeight(0.78f), + modifier = Modifier + .align(Alignment.Center) + .padding(end = 24.dp, bottom = 96.dp) + .width(420.dp) + .fillMaxHeight(0.78f), ) } @@ -352,13 +356,21 @@ fun ChatToggleButton( ) } +enum class ChatTab(val title: String){ + GLOBAL("Global"), + TEAM("Team"), + OPERATIVES("Operatives") +} + @Suppress("ktlint:standard:function-naming") @Composable fun ChatWindow( chatInput: String, messages: List, + selectedTab: ChatTab, + onTabSelected: (ChatTab) -> Unit, onChatInputChange: (String) -> Unit, - onSendClick: () -> Unit, + onSendClick: (ChatTab) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -369,13 +381,36 @@ fun ChatWindow( shape = RoundedCornerShape(12.dp), ).padding(12.dp), verticalArrangement = Arrangement.SpaceBetween, - ) { + ){ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ){ + ChatTab.values().forEach { tab -> + AppButton( + text = tab.title, + onClick = { onTabSelected(tab) }, + modifier = Modifier + .weight(1f) + .height(36.dp), + style = AppButtonStyle( + type = AppButtonType.PRIMARY, + containerColor = if (selectedTab == tab) Color.Unspecified else Color.Transparent, + contentColor = if (selectedTab == tab) Color.Unspecified else Color.LightGray, + fontSize = 11.sp, + lineHeight = 12.sp, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp) + ) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + ChatMessagesArea( messages = messages, - modifier = - Modifier - .weight(1f) - .fillMaxWidth(), + selectedTab = selectedTab, + modifier = Modifier.weight(1f).fillMaxWidth(), ) Spacer(modifier = Modifier.height(12.dp)) @@ -411,7 +446,7 @@ fun ChatWindow( AppButton( text = "Send", - onClick = onSendClick, + onClick = {onSendClick(selectedTab)}, modifier = Modifier .width(92.dp) @@ -432,6 +467,7 @@ fun ChatWindow( fun ChatMessagesArea( messages: List, modifier: Modifier = Modifier, + selectedTab: ChatTab, ) { Column( modifier = @@ -443,7 +479,7 @@ fun ChatMessagesArea( verticalArrangement = Arrangement.Top, ) { Text( - text = "Team Chat", + text = "${selectedTab.title} Chat", color = Color(0xFF383330), fontSize = 16.sp, fontWeight = FontWeight.Bold, From d35bd37f4d1c0a3f647900cb7c077b462ffef689 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 15 May 2026 13:50:10 +0200 Subject: [PATCH 056/121] fix: session view model is now passed as an argument to all pages, lobby flow complete --- .idea/misc.xml | 3 ++- .../frontend/network/provider/RetrofitProvider.kt | 2 +- .../frontend/network/websocket/GameWebSocketHandler.kt | 2 +- .../com/codenames/frontend/ui/navigation/NavGraph.kt | 5 ++++- .../com/codenames/frontend/ui/screens/LobbyScreen.kt | 9 +++++---- .../com/codenames/frontend/ui/screens/StartScreen.kt | 2 +- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 6c5519f..24c1037 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + 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..c18e691 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://10.0.2.2: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 d1d993f..8cffa59 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 @@ -12,7 +12,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConver import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject -const val BASE_URL = "ws://localhost:8080/ws-fallback" +const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" class GameWebSocketHandler @Inject 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 e4b0869..f0a4125 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 @@ -40,7 +40,10 @@ fun NavGraph( composable( Screen.Start.route, ) { - StartScreen(lobbyViewModel = lobbyViewModel, navController = navController) + StartScreen( + lobbyViewModel = lobbyViewModel, + navController = navController, + sessionViewModel = sessionViewModel) } composable(Screen.Lobby.route) { 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 2f4bfa1..04acfd4 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.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavHostController import com.codenames.frontend.data.model.LobbyUiState @@ -113,6 +112,8 @@ fun LobbyScreen( lobbyCode = lobbyUiState.lobbyCode ?: "", currentRole = currentRole, onStartGame = onStartGame, + viewModel = viewModel, + sessionViewModel = sessionViewModel ) TeamColumn( @@ -253,15 +254,15 @@ fun GameSettingsColumn( modifier: Modifier, navController: NavController, lobbyCode: String, - viewModel: LobbyViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), - sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), + viewModel: LobbyViewModel, + sessionViewModel: SessionViewModel, currentRole: PlayerRoles, onStartGame: () -> Unit, ) { val usernameState by sessionViewModel.username.collectAsState() val canStart = usernameState.username.isNotBlank() && - !lobbyCode.isNullOrBlank() && + lobbyCode.isNotBlank() && currentRole != PlayerRoles.NONE Column( 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 a829a9d..3094635 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 @@ -36,7 +36,7 @@ import com.codenames.frontend.viewmodel.SessionViewModel fun StartScreen( navController: NavHostController, lobbyViewModel: LobbyViewModel, - sessionViewModel: SessionViewModel = hiltViewModel(navController.getBackStackEntry("main_graph")), + sessionViewModel: SessionViewModel, ) { ForceLandscape() From bb2ae7db74506eb7c9bbd4064096ee3eaf8cb608 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 15 May 2026 13:59:56 +0200 Subject: [PATCH 057/121] change lobby api to use proper http methods --- .../codenames/frontend/data/repository/LobbyRepository.kt | 2 +- .../java/com/codenames/frontend/network/api/LobbyApi.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 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 c938df0..7ae6ee7 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 @@ -22,7 +22,7 @@ class LobbyRepository suspend fun joinLobby( username: String, lobbyCode: String, - ): LobbyResponse = api.joinLobby(username, lobbyCode) + ): LobbyResponse = api.joinLobby(lobbyCode, username) suspend fun getLobbyInfo(lobbyCode: String): LobbyResponse = api.getLobbyInfo(lobbyCode) 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 c5fb3ad..b72018d 100644 --- a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt +++ b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt @@ -10,15 +10,15 @@ import retrofit2.http.Query // will be extended / adapted to different endpoints when backend is ready interface LobbyApi { - @POST("lobby/create") + @GET("lobby/create") suspend fun createLobby( @Query("username") username: String, ): LobbyResponse - @POST("lobby/join") + @GET("lobby/{lobbyCode}/join") suspend fun joinLobby( + @Path("lobbyCode") lobbyCode: String, @Query("username") username: String, - @Query("lobbyCode") lobbyCode: String, ): LobbyResponse @GET("lobby/{lobbyCode}") @@ -26,7 +26,7 @@ interface LobbyApi { @Path("lobbyCode") lobbyCode: String, ): LobbyResponse - @POST("lobby/{lobbyCode}/leave") + @GET("lobby/{lobbyCode}/leave") suspend fun leaveLobby( @Path("lobbyCode") lobbyCode: String, @Query("username") username: String, From 521f16878078892c4c5c6b4038b40d571cf34554 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 15 May 2026 14:02:22 +0200 Subject: [PATCH 058/121] removed logs --- .../main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt | 2 -- 1 file changed, 2 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 04acfd4..c1e6918 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt @@ -1,6 +1,5 @@ package com.codenames.frontend.ui.screens -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -223,7 +222,6 @@ fun RoleCard( verticalArrangement = Arrangement.SpaceBetween, ) { Text(title, color = Color.White, fontWeight = FontWeight.Bold) - Log.d("LobbyScreen", "Players: $players") if (players.isEmpty()) { Text( text = "No players", From ca2ff676f43baae4f18d7a161a3b748f0af5409f Mon Sep 17 00:00:00 2001 From: 5eli Date: Fri, 15 May 2026 17:53:02 +0200 Subject: [PATCH 059/121] added the ui-state-update for the chat-system --- .idea/misc.xml | 1 - .../frontend/data/model/ChatMessage.kt | 15 +++ .../frontend/data/model/ChatUiState.kt | 6 + .../frontend/data/model/enums/ChatTab.kt | 9 ++ .../frontend/ui/screens/GameboardScreen.kt | 103 +++++++++--------- .../frontend/viewmodel/ChatViewModel.kt | 75 +++++++++++++ 6 files changed, 159 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/ChatMessage.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt create mode 100644 app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt 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/data/model/ChatMessage.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatMessage.kt new file mode 100644 index 0000000..9264389 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatMessage.kt @@ -0,0 +1,15 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.ChatTab +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessage( + val id: Int, + val sender: String, + val message: String, + val timestamp: String, + val chatTab: ChatTab, + val isSystemMessage: Boolean = false, + val isFromMe: Boolean = false, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt new file mode 100644 index 0000000..31fb8cb --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt @@ -0,0 +1,6 @@ +package com.codenames.frontend.data.model + +data class ChatUiState( + val messages: List = emptyList(), + val currentInput: String = "", +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt new file mode 100644 index 0000000..d77829c --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt @@ -0,0 +1,9 @@ +package com.codenames.frontend.data.model.enums + +enum class ChatTab( + val title: String, +) { + GLOBAL("Global"), + TEAM("Team"), + OPERATIVES("Operatives"), +} diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt index 7c590ce..8f812b2 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -16,13 +17,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.layout.PaddingValues 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.collectAsState 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 @@ -42,10 +44,14 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.data.model.ChatMessage +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.AppButtonType import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.composables.GameBoardGrid import com.codenames.frontend.ui.inputs.AppTextField @@ -53,7 +59,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.buttons.AppButtonType +import com.codenames.frontend.viewmodel.ChatViewModel enum class CardType { BLUE, @@ -144,6 +150,7 @@ fun GameboardScreen( modifier: Modifier = Modifier, onSendChatMessage: (String) -> Unit = {}, onSettingsClick: (() -> Unit)? = null, + chatViewModel: ChatViewModel = viewModel(), ) { val currentHint = gameState.currentHint val cards = gameState.cards @@ -152,10 +159,9 @@ fun GameboardScreen( val remainingGuesses = gameState.remainingGuesses val currentRedFound = gameState.currentRedFound val currentBlueFound = gameState.currentBlueFound - val chatMessages = gameState.chatMessages + val chatUiState by chatViewModel.uiState.collectAsState() var hintInput by rememberSaveable { mutableStateOf("") } - var chatInput by rememberSaveable { mutableStateOf("") } var isChatOpen by rememberSaveable { mutableStateOf(false) } var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } @@ -261,24 +267,23 @@ fun GameboardScreen( if (!isSpymaster && isChatOpen) { ChatWindow( - chatInput = chatInput, - messages = chatMessages, + chatInput = chatUiState.currentInput, + messages = chatUiState.messages, selectedTab = selectedChatTab, onTabSelected = { selectedChatTab = it }, - onChatInputChange = { chatInput = it }, + onChatInputChange = { chatViewModel.updateInput(it) }, onSendClick = { tab -> - if (chatInput.isNotBlank()) { - onSendChatMessage(chatInput) - chatInput = "" - focusManager.clearFocus() - keyboardController?.hide() - } + chatViewModel.sendMessage( + username = "Player1", + tab = tab, + ) }, - modifier = Modifier - .align(Alignment.Center) - .padding(end = 24.dp, bottom = 96.dp) - .width(420.dp) - .fillMaxHeight(0.78f), + modifier = + Modifier + .align(Alignment.Center) + .padding(end = 24.dp, bottom = 96.dp) + .width(420.dp) + .fillMaxHeight(0.78f), ) } @@ -356,17 +361,11 @@ fun ChatToggleButton( ) } -enum class ChatTab(val title: String){ - GLOBAL("Global"), - TEAM("Team"), - OPERATIVES("Operatives") -} - @Suppress("ktlint:standard:function-naming") @Composable fun ChatWindow( chatInput: String, - messages: List, + messages: List, selectedTab: ChatTab, onTabSelected: (ChatTab) -> Unit, onChatInputChange: (String) -> Unit, @@ -381,26 +380,28 @@ fun ChatWindow( shape = RoundedCornerShape(12.dp), ).padding(12.dp), verticalArrangement = Arrangement.SpaceBetween, - ){ + ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ){ - ChatTab.values().forEach { tab -> + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatTab.entries.forEach { tab -> AppButton( text = tab.title, onClick = { onTabSelected(tab) }, - modifier = Modifier - .weight(1f) - .height(36.dp), - style = AppButtonStyle( - type = AppButtonType.PRIMARY, - containerColor = if (selectedTab == tab) Color.Unspecified else Color.Transparent, - contentColor = if (selectedTab == tab) Color.Unspecified else Color.LightGray, - fontSize = 11.sp, - lineHeight = 12.sp, - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp) - ) + modifier = + Modifier + .weight(1f) + .height(36.dp), + style = + AppButtonStyle( + type = AppButtonType.PRIMARY, + containerColor = if (selectedTab == tab) Color.Unspecified else Color.Transparent, + contentColor = if (selectedTab == tab) Color.Unspecified else Color.LightGray, + fontSize = 11.sp, + lineHeight = 12.sp, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + ), ) } } @@ -446,7 +447,7 @@ fun ChatWindow( AppButton( text = "Send", - onClick = {onSendClick(selectedTab)}, + onClick = { onSendClick(selectedTab) }, modifier = Modifier .width(92.dp) @@ -465,7 +466,7 @@ fun ChatWindow( @Suppress("ktlint:standard:function-naming") @Composable fun ChatMessagesArea( - messages: List, + messages: List, modifier: Modifier = Modifier, selectedTab: ChatTab, ) { @@ -497,8 +498,12 @@ fun ChatMessagesArea( LazyColumn( verticalArrangement = Arrangement.spacedBy(6.dp), ) { - items(messages) { message -> - ChatMessageBubble(message) + items( + messages.filter { + it.chatTab == selectedTab + }, + ) { message -> + ChatMessageBubble(message = message) } } } @@ -507,7 +512,7 @@ fun ChatMessagesArea( @Suppress("ktlint:standard:function-naming") @Composable -fun ChatMessageBubble(message: ChatDomainModel) { +fun ChatMessageBubble(message: ChatMessage) { 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) @@ -531,7 +536,7 @@ fun ChatMessageBubble(message: ChatDomainModel) { .padding(8.dp), ) { Text( - text = message.text, + text = message.message, color = textColor, fontSize = 13.sp, ) @@ -736,9 +741,9 @@ fun getColor(type: CardType): Color = fun OfflineGameStateTestScreen() { var currentHint by rememberSaveable { mutableStateOf("EAGLE") } var currentTurn by rememberSaveable { mutableStateOf("BLUE") } - var remainingGuesses by rememberSaveable { mutableStateOf(3) } - var currentBlueFound by rememberSaveable { mutableStateOf(0) } - var currentRedFound by rememberSaveable { mutableStateOf(0) } + var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } + var currentBlueFound by rememberSaveable { mutableIntStateOf(0) } + var currentRedFound by rememberSaveable { mutableIntStateOf(0) } val cards = remember { diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..508927b --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -0,0 +1,75 @@ +package com.codenames.frontend.viewmodel + +import androidx.lifecycle.ViewModel +import com.codenames.frontend.data.model.ChatMessage +import com.codenames.frontend.data.model.ChatUiState +import com.codenames.frontend.data.model.enums.ChatTab +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ChatViewModel : ViewModel() { + private val _uiState = + MutableStateFlow( + ChatUiState( + messages = + listOf( + ChatMessage( + id = 1, + sender = "SYSTEM", + message = "Game started", + timestamp = "12:00", + chatTab = ChatTab.GLOBAL, + ), + ChatMessage( + id = 2, + sender = "Anna", + message = "Hello team", + timestamp = "12:01", + chatTab = ChatTab.TEAM, + ), + ChatMessage( + id = 3, + sender = "Max", + message = "Lets win this", + timestamp = "12:02", + chatTab = ChatTab.OPERATIVES, + ), + ), + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateInput(newInput: String) { + _uiState.value = + _uiState.value.copy( + currentInput = newInput, + ) + } + + fun sendMessage( + username: String, + tab: ChatTab, + ) { + val text = _uiState.value.currentInput.trim() + + if (text.isBlank()) { + return + } + + val newMessage = + ChatMessage( + id = _uiState.value.messages.size + 1, + sender = username, + message = text, + timestamp = "NOW", + chatTab = tab, + ) + + _uiState.value = + _uiState.value.copy( + messages = _uiState.value.messages + newMessage, + currentInput = "", + ) + } +} From f183376bcae490f6e790df30c5828ad69c2f145a Mon Sep 17 00:00:00 2001 From: 5eli Date: Fri, 15 May 2026 18:21:24 +0200 Subject: [PATCH 060/121] added ChatViewModelTest --- .../frontend/viewmodel/ChatViewModelTest.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt new file mode 100644 index 0000000..4e947c1 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -0,0 +1,82 @@ +package com.codenames.frontend.viewmodel + +import com.codenames.frontend.data.model.enums.ChatTab +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class ChatViewModelTest { + + private lateinit var viewModel: ChatViewModel + + @Before + fun setup() { + viewModel = ChatViewModel() + } + + @Test + fun updateInput_updatesCurrentInput() { + viewModel.updateInput("Hallo") + + assertEquals( + "Hallo", + viewModel.uiState.value.currentInput + ) + } + + @Test + fun sendMessage_addsMessage() { + viewModel.updateInput("Neue Nachricht") + + val before = + viewModel.uiState.value.messages.size + + viewModel.sendMessage( + username = "Max", + tab = ChatTab.GLOBAL + ) + + val state = viewModel.uiState.value + + assertEquals(before + 1, state.messages.size) + + val last = state.messages.last() + + assertEquals("Max", last.sender) + assertEquals("Neue Nachricht", last.message) + assertEquals(ChatTab.GLOBAL, last.chatTab) + } + + @Test + fun sendMessage_clearsInputAfterSend() { + viewModel.updateInput("Text") + + viewModel.sendMessage( + "Max", + ChatTab.GLOBAL + ) + + assertEquals( + "", + viewModel.uiState.value.currentInput + ) + } + + @Test + fun sendMessage_blankMessage_doesNothing() { + viewModel.updateInput(" ") + + val before = + viewModel.uiState.value.messages.size + + viewModel.sendMessage( + "Max", + ChatTab.GLOBAL + ) + + val after = + viewModel.uiState.value.messages.size + + assertEquals(before, after) + } +} \ No newline at end of file From f02e237535fbe3bc79806e989b5bf159585a3889 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 15 May 2026 18:29:33 +0200 Subject: [PATCH 061/121] sonar: fixed build issues (just formatting), also fixed one failing test --- .../java/com/codenames/frontend/ui/navigation/NavGraph.kt | 3 ++- .../java/com/codenames/frontend/ui/screens/LobbyScreen.kt | 4 ++-- .../java/com/codenames/frontend/ui/screens/StartScreen.kt | 1 - .../codenames/frontend/data/repository/LobbyRepositoryTest.kt | 4 ++-- 4 files changed, 6 insertions(+), 6 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 f0a4125..f04ce85 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 @@ -43,7 +43,8 @@ fun NavGraph( StartScreen( lobbyViewModel = lobbyViewModel, navController = navController, - sessionViewModel = sessionViewModel) + sessionViewModel = sessionViewModel, + ) } composable(Screen.Lobby.route) { 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 c1e6918..1aeda01 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 @@ -112,7 +112,7 @@ fun LobbyScreen( currentRole = currentRole, onStartGame = onStartGame, viewModel = viewModel, - sessionViewModel = sessionViewModel + sessionViewModel = sessionViewModel, ) TeamColumn( @@ -260,7 +260,7 @@ fun GameSettingsColumn( val usernameState by sessionViewModel.username.collectAsState() val canStart = usernameState.username.isNotBlank() && - lobbyCode.isNotBlank() && + lobbyCode.isNotBlank() && currentRole != PlayerRoles.NONE Column( 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 3094635..8446760 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt @@ -20,7 +20,6 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle diff --git a/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt index 605a3bb..8bdbae9 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 @@ -51,11 +51,11 @@ class LobbyRepositoryTest { val response = LobbyResponse(lobbyCode, emptyList()) - coEvery { api.joinLobby(username, lobbyCode) } returns response + coEvery { api.joinLobby(lobbyCode, username) } returns response val result = repository.joinLobby(username, lobbyCode) - coVerify { api.joinLobby(username, lobbyCode) } + coVerify { api.joinLobby(lobbyCode, username) } assertEquals(response, result) } From a04cb7d4391da0c966d6a6406e86d70338c4d6d8 Mon Sep 17 00:00:00 2001 From: 5eli Date: Fri, 15 May 2026 18:33:42 +0200 Subject: [PATCH 062/121] formatted the code properly --- .../frontend/viewmodel/ChatViewModelTest.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 4e947c1..4aa0901 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -1,12 +1,11 @@ package com.codenames.frontend.viewmodel import com.codenames.frontend.data.model.enums.ChatTab -import org.junit.Assert.* +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test class ChatViewModelTest { - private lateinit var viewModel: ChatViewModel @Before @@ -20,7 +19,7 @@ class ChatViewModelTest { assertEquals( "Hallo", - viewModel.uiState.value.currentInput + viewModel.uiState.value.currentInput, ) } @@ -33,7 +32,7 @@ class ChatViewModelTest { viewModel.sendMessage( username = "Max", - tab = ChatTab.GLOBAL + tab = ChatTab.GLOBAL, ) val state = viewModel.uiState.value @@ -53,15 +52,17 @@ class ChatViewModelTest { viewModel.sendMessage( "Max", - ChatTab.GLOBAL + ChatTab.GLOBAL, ) assertEquals( "", - viewModel.uiState.value.currentInput + viewModel.uiState.value.currentInput, ) } + + @Test fun sendMessage_blankMessage_doesNothing() { viewModel.updateInput(" ") @@ -71,7 +72,7 @@ class ChatViewModelTest { viewModel.sendMessage( "Max", - ChatTab.GLOBAL + ChatTab.GLOBAL, ) val after = @@ -79,4 +80,6 @@ class ChatViewModelTest { assertEquals(before, after) } -} \ No newline at end of file + + +} From 2e0ca3892fe45daa98a1535a9a1a5627dddfd482 Mon Sep 17 00:00:00 2001 From: 5eli Date: Fri, 15 May 2026 18:37:55 +0200 Subject: [PATCH 063/121] formatted the code properly --- .../com/codenames/frontend/viewmodel/ChatViewModelTest.kt | 4 ---- 1 file changed, 4 deletions(-) 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 4aa0901..874672e 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -61,8 +61,6 @@ class ChatViewModelTest { ) } - - @Test fun sendMessage_blankMessage_doesNothing() { viewModel.updateInput(" ") @@ -80,6 +78,4 @@ class ChatViewModelTest { assertEquals(before, after) } - - } From 487e71aa3dfe3d2628bc2c530613aff007935e28 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 15 May 2026 18:50:58 +0200 Subject: [PATCH 064/121] test: added tests for lobby view model --- .../frontend/viewmodel/LobbyViewModelTest.kt | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) 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 3605bb0..3dc9c9e 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -23,6 +23,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -536,4 +537,134 @@ class LobbyViewModelTest { viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob") } } + @Test + fun `getRoleForUser returns BLUE_OPERATIVE`() = runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val players = listOf( + PlayerDto( + username = "Max", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = true + ) + ) + + val response = LobbyResponse( + lobbyCode = "ABCD", + playerList = players + ) + + coEvery { + repository.joinLobby("Max", "ABCD") + } returns response + + viewModel.joinLobby("Max", "ABCD") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Max") + + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } + + @Test + fun `getRoleForUser returns NONE when player does not exist`() = runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val result = viewModel.getRoleForUser("Unknown") + + assertEquals(PlayerRoles.NONE, result) + } + + @Test + fun `changeRole updates player role correctly`() = runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val initialPlayers = listOf( + PlayerDto( + username = "Max", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = true + ) + ) + + val updatedPlayers = listOf( + PlayerDto( + username = "Max", + role = Role.SPYMASTER, + team = Team.RED, + isHost = false + ) + ) + + val joinResponse = LobbyResponse( + lobbyCode = "ABCD", + playerList = initialPlayers + ) + + val changeRoleResponse = LobbyResponse( + lobbyCode = "ABCD", + playerList = updatedPlayers + ) + + coEvery { + repository.joinLobby("Max", "ABCD") + } returns joinResponse + + coEvery { + repository.changeRole( + "Max", + "ABCD", + Role.SPYMASTER, + Team.RED + ) + } returns changeRoleResponse + + viewModel.joinLobby("Max", "ABCD") + advanceUntilIdle() + + viewModel.changeRole( + role = Role.SPYMASTER, + team = Team.RED, + username = "Max" + ) + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Max") + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + + coVerify(exactly = 1) { + repository.changeRole( + "Max", + "ABCD", + Role.SPYMASTER, + Team.RED + ) + } + } + + @Test + fun `changeRole sets error when not in lobby`() = runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + viewModel.changeRole( + role = Role.SPYMASTER, + team = Team.RED, + username = "Max" + ) + + advanceUntilIdle() + + assertTrue( + viewModel.state.value.error?.contains("Not in a Lobby") == true + ) + } } From 822fe84a98c9417ac5eb38301a956069e10f892b Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 15 May 2026 18:57:54 +0200 Subject: [PATCH 065/121] formatted tests --- .../frontend/viewmodel/LobbyViewModelTest.kt | 214 +++++++++--------- 1 file changed, 113 insertions(+), 101 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 3dc9c9e..c132983 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -537,134 +537,146 @@ class LobbyViewModelTest { viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob") } } + @Test - fun `getRoleForUser returns BLUE_OPERATIVE`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + fun `getRoleForUser returns BLUE_OPERATIVE`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) - val players = listOf( - PlayerDto( - username = "Max", - role = Role.OPERATIVE, - team = Team.BLUE, - isHost = true - ) - ) + val players = + listOf( + PlayerDto( + username = "Max", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = true, + ), + ) - val response = LobbyResponse( - lobbyCode = "ABCD", - playerList = players - ) + val response = + LobbyResponse( + lobbyCode = "ABCD", + playerList = players, + ) - coEvery { - repository.joinLobby("Max", "ABCD") - } returns response + coEvery { + repository.joinLobby("Max", "ABCD") + } returns response - viewModel.joinLobby("Max", "ABCD") + viewModel.joinLobby("Max", "ABCD") - advanceUntilIdle() + advanceUntilIdle() - val result = viewModel.getRoleForUser("Max") + val result = viewModel.getRoleForUser("Max") - assertEquals(PlayerRoles.BLUE_OPERATIVE, result) - } + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } @Test - fun `getRoleForUser returns NONE when player does not exist`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + fun `getRoleForUser returns NONE when player does not exist`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) - val result = viewModel.getRoleForUser("Unknown") + val result = viewModel.getRoleForUser("Unknown") - assertEquals(PlayerRoles.NONE, result) - } + assertEquals(PlayerRoles.NONE, result) + } @Test - fun `changeRole updates player role correctly`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + fun `changeRole updates player role correctly`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) - val initialPlayers = listOf( - PlayerDto( - username = "Max", - role = Role.OPERATIVE, - team = Team.BLUE, - isHost = true - ) - ) + val initialPlayers = + listOf( + PlayerDto( + username = "Max", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = true, + ), + ) - val updatedPlayers = listOf( - PlayerDto( - username = "Max", - role = Role.SPYMASTER, - team = Team.RED, - isHost = false - ) - ) - - val joinResponse = LobbyResponse( - lobbyCode = "ABCD", - playerList = initialPlayers - ) - - val changeRoleResponse = LobbyResponse( - lobbyCode = "ABCD", - playerList = updatedPlayers - ) - - coEvery { - repository.joinLobby("Max", "ABCD") - } returns joinResponse - - coEvery { - repository.changeRole( - "Max", - "ABCD", - Role.SPYMASTER, - Team.RED - ) - } returns changeRoleResponse + val updatedPlayers = + listOf( + PlayerDto( + username = "Max", + role = Role.SPYMASTER, + team = Team.RED, + isHost = false, + ), + ) - viewModel.joinLobby("Max", "ABCD") - advanceUntilIdle() + val joinResponse = + LobbyResponse( + lobbyCode = "ABCD", + playerList = initialPlayers, + ) - viewModel.changeRole( - role = Role.SPYMASTER, - team = Team.RED, - username = "Max" - ) + val changeRoleResponse = + LobbyResponse( + lobbyCode = "ABCD", + playerList = updatedPlayers, + ) - advanceUntilIdle() + coEvery { + repository.joinLobby("Max", "ABCD") + } returns joinResponse - val result = viewModel.getRoleForUser("Max") + coEvery { + repository.changeRole( + "Max", + "ABCD", + Role.SPYMASTER, + Team.RED, + ) + } returns changeRoleResponse - assertEquals(PlayerRoles.RED_SPYMASTER, result) + viewModel.joinLobby("Max", "ABCD") + advanceUntilIdle() - coVerify(exactly = 1) { - repository.changeRole( - "Max", - "ABCD", - Role.SPYMASTER, - Team.RED + viewModel.changeRole( + role = Role.SPYMASTER, + team = Team.RED, + username = "Max", ) + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Max") + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + + coVerify(exactly = 1) { + repository.changeRole( + "Max", + "ABCD", + Role.SPYMASTER, + Team.RED, + ) + } } - } @Test - fun `changeRole sets error when not in lobby`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + fun `changeRole sets error when not in lobby`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) - viewModel.changeRole( - role = Role.SPYMASTER, - team = Team.RED, - username = "Max" - ) + viewModel.changeRole( + role = Role.SPYMASTER, + team = Team.RED, + username = "Max", + ) - advanceUntilIdle() + advanceUntilIdle() - assertTrue( - viewModel.state.value.error?.contains("Not in a Lobby") == true - ) - } + assertTrue( + viewModel.state.value.error + ?.contains("Not in a Lobby") == true, + ) + } } From 12fd1bce5cf797b42731b52c7c7b031f3226e5cf Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 16 May 2026 10:10:54 +0200 Subject: [PATCH 066/121] sonar: changed IP back to sonar --- .idea/misc.xml | 1 - .../com/codenames/frontend/network/provider/RetrofitProvider.kt | 2 +- .../frontend/network/websocket/GameWebSocketHandler.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 24c1037..a1d4940 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - 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 c18e691..3d1b1f5 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://10.0.2.2:8080/" +const val BASE_URL = "http://localhost:8080/" @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt index 8cffa59..d1d993f 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 @@ -12,7 +12,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConver import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject -const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" +const val BASE_URL = "ws://localhost:8080/ws-fallback" class GameWebSocketHandler @Inject From fb4148d2b0e35854ecc2c2a218989a9179588ec7 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 16 May 2026 23:25:29 +0200 Subject: [PATCH 067/121] feat: add DTO for clue --- .../java/com/codenames/frontend/network/dto/ClueDto.kt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt new file mode 100644 index 0000000..4b3dc83 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt @@ -0,0 +1,7 @@ +package com.codenames.frontend.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ClueDto(val word: String, val count: Int) { +} \ No newline at end of file From f37afa7fe68feedee8fd3127c6cc20dd87292e26 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 16 May 2026 23:26:25 +0200 Subject: [PATCH 068/121] feat: add currentPhasae to GameMessage to match backend DTO sent --- .../java/com/codenames/frontend/data/model/enums/Role.kt | 3 +++ .../java/com/codenames/frontend/data/model/enums/Team.kt | 3 +++ .../java/com/codenames/frontend/network/dto/GameMessage.kt | 7 +++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt index 56ce7d9..f0e6ba1 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt @@ -1,5 +1,8 @@ package com.codenames.frontend.data.model.enums +import kotlinx.serialization.Serializable + +@Serializable enum class Role { OPERATIVE, SPYMASTER, diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt index d7d53c8..08de36d 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt @@ -1,5 +1,8 @@ package com.codenames.frontend.data.model.enums +import kotlinx.serialization.Serializable + +@Serializable enum class Team { RED, BLUE, diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt index d0acd9e..3667b99 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt @@ -1,11 +1,14 @@ package com.codenames.frontend.network.dto +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import kotlinx.serialization.Serializable @Serializable data class GameMessage( - val winner: String? = null, - val currentTurn: String = "", + val winner: Team? = null, + val currentTurn: Team? = null, + val currentPhase: Role? = null, val currentRedFound: Int = 0, val currentBlueFound: Int = 0, val currentClue: String? = null, From fd88f9e2b984784f0365e2f469f68b9e9863d72b Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 16 May 2026 23:30:58 +0200 Subject: [PATCH 069/121] feat: add method to send clue to the backend --- .../network/websocket/GameWebSocketHandler.kt | 7 +++++++ .../com/codenames/frontend/viewmodel/GameViewModel.kt | 11 +++++++++++ 2 files changed, 18 insertions(+) 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 d1d993f..38b5d29 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,7 @@ package com.codenames.frontend.network.websocket import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.dto.ClueDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage @@ -46,4 +47,10 @@ class GameWebSocketHandler ) { session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } + + suspend fun sendClue(lobbyCode: String, msg: ClueDto) { + session.convertAndSend("/app/game/clue/$lobbyCode", msg, ClueDto.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 05269db..60ae06f 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -6,6 +6,7 @@ import com.codenames.frontend.data.model.ChatLists import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.repository.ChatRepository +import com.codenames.frontend.network.dto.ClueDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler @@ -127,6 +128,16 @@ class GameViewModel } } + fun submitClue(lobbyCode: String, word: String, count: Int) { + viewModelScope.launch { + try { + client.sendClue(lobbyCode, ClueDto(word, count)) + } catch (e: Exception) { + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + } + } + } + fun handleMessage(message: GameMessage) { _uiState.value = message // Add logic to handle incoming messages From 02b8b6620fc218920b01ccbaf40d53d3ca1069c8 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:24:31 +0200 Subject: [PATCH 070/121] refactor: change string to enum --- .../frontend/ui/screens/GameboardScreen.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 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 4054721..47a49ce 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 @@ -48,6 +48,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.codenames.frontend.data.model.ChatDomainModel import com.codenames.frontend.data.model.ChatMessage 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.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -80,8 +81,9 @@ data class GameCard( data class GameState( val currentHint: String, val cards: List, - val currentTurn: String = "", - val winner: String? = null, + val currentTurn: Team? = null, + val currentPhase: Role? = null, + val winner: Team? = null, val remainingGuesses: Int = 0, val currentRedFound: Int = 0, val currentBlueFound: Int = 0, @@ -257,8 +259,8 @@ fun GameboardScreen( @Suppress("ktlint:standard:function-naming") @Composable fun GameStatusBar( - currentTurn: String, - winner: String?, + currentTurn: Team?, + winner: Team?, remainingGuesses: Int, ) { Row( @@ -271,8 +273,8 @@ fun GameStatusBar( ) { val statusText = when { - !winner.isNullOrBlank() -> "Winner: $winner" - currentTurn.isNotBlank() -> "Turn: $currentTurn | Guesses: $remainingGuesses" + winner != null -> "Winner: $winner" + currentTurn != null -> "Turn: $currentTurn | Guesses: $remainingGuesses" else -> "Waiting for turn..." } From 1147c09d01e83604499b65223391c2b1104dac93 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:25:15 +0200 Subject: [PATCH 071/121] refactor: change string to enum to match gaame message dto --- .../com/codenames/frontend/ui/screens/GameboardScreen.kt | 6 +++--- 1 file changed, 3 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 47a49ce..274931a 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 @@ -690,7 +690,7 @@ fun getColor(type: CardType): Color = @Composable fun OfflineGameStateTestScreen() { var currentHint by rememberSaveable { mutableStateOf("EAGLE") } - var currentTurn by rememberSaveable { mutableStateOf("BLUE") } + var currentTurn by rememberSaveable { mutableStateOf(Team.BLUE) } var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } var currentBlueFound by rememberSaveable { mutableIntStateOf(0) } var currentRedFound by rememberSaveable { mutableIntStateOf(0) } @@ -750,8 +750,8 @@ fun OfflineGameStateTestScreen() { when (card.type) { CardType.BLUE -> currentBlueFound++ CardType.RED -> currentRedFound++ - CardType.NEUTRAL -> currentTurn = if (currentTurn == "BLUE") "RED" else "BLUE" - CardType.ASSASSIN -> currentTurn = "GAME OVER" + CardType.NEUTRAL -> currentTurn = if (currentTurn == Team.BLUE) Team.RED else Team.BLUE + CardType.ASSASSIN -> currentTurn = if (currentTurn == Team.BLUE) Team.RED else Team.BLUE } if (remainingGuesses > 0) { From 6fa7fd6fc70026b6b6007d13504afe027ed3baa6 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:26:04 +0200 Subject: [PATCH 072/121] refactor: change string to enum to match game message dto in test class --- .../frontend/viewmodel/GameViewModelTest.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 313caa7..0850183 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -5,6 +5,7 @@ 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.network.dto.CardDto +import com.codenames.frontend.network.dto.ClueDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler @@ -40,8 +41,9 @@ class GameViewModelTest { private val testMessage = GameMessage( - "", - "red", + Team.RED, + Team.RED, + currentPhase = Role.OPERATIVE, 0, 0, "", @@ -232,7 +234,8 @@ class GameViewModelTest { val message = GameMessage( winner = null, - currentTurn = "BLUE", + currentTurn = Team.BLUE, + currentPhase = Role.OPERATIVE, currentRedFound = 1, currentBlueFound = 2, currentClue = "EAGLE", @@ -248,7 +251,8 @@ class GameViewModelTest { val state = viewModel.uiState.value - assertEquals("BLUE", state.currentTurn) + assertEquals(Team.BLUE, state.currentTurn) + assertEquals(Role.OPERATIVE, state.currentPhase) assertEquals("EAGLE", state.currentClue) assertEquals(3, state.remainingGuesses) assertEquals(2, state.cardList.size) From a61f05db8add2b882be7cea4642b462f039d3f8b Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:26:51 +0200 Subject: [PATCH 073/121] test: add test to verify sending clue --- .../frontend/viewmodel/GameViewModelTest.kt | 14 ++++++++++++++ 1 file changed, 14 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 0850183..276f765 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -258,4 +258,18 @@ class GameViewModelTest { assertEquals(2, state.cardList.size) assertEquals("BERLIN", state.cardList[0].word) } + + @Test + fun testSubmitClue() = runTest { + val word = "EAGLE" + val count = 2 + + viewModel.submitClue(lobbyCode, word, count) + advanceUntilIdle() + + coVerify { + client.sendClue(lobbyCode, ClueDto(word, count)) + } + } + } From b119c0b41e1299bcf139645889121a7ec134e4ab Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:27:12 +0200 Subject: [PATCH 074/121] refactor: fix indent --- .../codenames/frontend/viewmodel/GameViewModel.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 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 60ae06f..84701e7 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -128,15 +128,15 @@ class GameViewModel } } - fun submitClue(lobbyCode: String, word: String, count: Int) { - viewModelScope.launch { - try { - client.sendClue(lobbyCode, ClueDto(word, count)) - } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + fun submitClue(lobbyCode: String, word: String, count: Int) { + viewModelScope.launch { + try { + client.sendClue(lobbyCode, ClueDto(word, count)) + } catch (e: Exception) { + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + } } } - } fun handleMessage(message: GameMessage) { _uiState.value = message From 52ee0d29ff2c0a5bb89a0931719f452a3bbef2b6 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:29:23 +0200 Subject: [PATCH 075/121] feat: add sending clue to game screen wrapper --- .../codenames/frontend/ui/screens/GameScreenWrapper.kt | 9 +++++++-- 1 file changed, 7 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 273ec66..267d483 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 @@ -38,6 +38,7 @@ fun GameScreenWrapper( GameState( currentHint = gameState.currentClue ?: "Waiting for hint...", currentTurn = gameState.currentTurn, + currentPhase = gameState.currentPhase, winner = gameState.winner, remainingGuesses = gameState.remainingGuesses, currentRedFound = gameState.currentRedFound, @@ -45,8 +46,12 @@ fun GameScreenWrapper( cards = cards, chatMessages = chatState.teamMessages, ), - onHintChange = { - // TODO: Send clue through GameViewModel once backend endpoint exists. + onHintChange = { word, count -> + + if (lobbyCode.isNotBlank()) { + gameViewModel.submitClue(lobbyCode, word, count) + } + // TODO: Check if I implemented the correct endpoint in submitClue. }, onReveal = { // TODO: Send guess through GameViewModel once backend endpoint exists. From 1360a35b3ecd360b91c0c03cbff5efae61cbb788 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:36:22 +0200 Subject: [PATCH 076/121] feat: add clue display to screen --- .../frontend/ui/screens/GameboardScreen.kt | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 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 274931a..dca56fb 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 @@ -95,7 +95,7 @@ data class GameState( fun GameboardScreen( userRole: PlayerRoles, gameState: GameState, - onHintChange: (String) -> Unit, + onHintChange: (String, Int) -> Unit, onReveal: (Int) -> Unit, modifier: Modifier = Modifier, onSendChatMessage: (String) -> Unit = {}, @@ -112,6 +112,7 @@ fun GameboardScreen( val chatUiState by chatViewModel.uiState.collectAsState() var hintInput by rememberSaveable { mutableStateOf("") } + var countInput by rememberSaveable { mutableStateOf("") } var isChatOpen by rememberSaveable { mutableStateOf(false) } var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } @@ -208,8 +209,10 @@ fun GameboardScreen( isSpymaster, currentHint, hintInput, - onHintChange, + countInput, + onHintChange = onHintChange, onInputChange, + onCountChange = { countInput = it }, keyboardController, focusManager, ) @@ -555,8 +558,10 @@ fun HintSection( isSpymaster: Boolean, currentHint: String, hintInput: String, - onHintChange: (String) -> Unit, + countInput: String, + onHintChange: (String, Int) -> Unit, onInputChange: (String) -> Unit, + onCountChange: (String) -> Unit, keyboardController: SoftwareKeyboardController?, focusManager: FocusManager, ) { @@ -576,9 +581,11 @@ fun HintSection( actions = KeyboardActions( onSend = { + val count = countInput.toIntOrNull() ?: 0 if (hintInput.isNotBlank()) { - onHintChange(hintInput.uppercase()) + onHintChange(hintInput.uppercase(), count) onInputChange("") + onCountChange("") focusManager.clearFocus() keyboardController?.hide() } @@ -586,13 +593,23 @@ fun HintSection( ), ), ) + AppTextField( + value = countInput, + onValueChange = onCountChange, + modifier = Modifier.width(80.dp), + state = AppTextFieldState(label = "COUNT", placeholder = "0") + ) AppButton( text = "SEND", onClick = { + val count = countInput.toIntOrNull() ?: 0 if (hintInput.isNotBlank()) { - onHintChange(hintInput.uppercase()) + onHintChange(hintInput.uppercase(), count) onInputChange("") + onCountChange("") + focusManager.clearFocus() + keyboardController?.hide() } }, ) @@ -770,8 +787,11 @@ fun OfflineGameStateTestScreen() { currentRedFound = currentRedFound, cards = cards, chatMessages = chatMessages, + currentPhase = Role.OPERATIVE ), - onHintChange = { currentHint = it }, + onHintChange = { word, count -> + currentHint = word + remainingGuesses = count }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, ) From 9bd9351db6c2f8250edb8f7849fe312d0a9bbd94 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:54:31 +0200 Subject: [PATCH 077/121] fix: change destination to match backend --- .../frontend/network/websocket/GameWebSocketHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 38b5d29..fe854bb 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 @@ -49,7 +49,7 @@ class GameWebSocketHandler } suspend fun sendClue(lobbyCode: String, msg: ClueDto) { - session.convertAndSend("/app/game/clue/$lobbyCode", msg, ClueDto.serializer()) + session.convertAndSend("/app/submit-clue", msg, ClueDto.serializer()) } From 53f3a89010b89260b4af918564dce1d03d624842 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:54:43 +0200 Subject: [PATCH 078/121] fix: change attribute to match backend serialization --- app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt index 4b3dc83..68adec8 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt @@ -3,5 +3,5 @@ package com.codenames.frontend.network.dto import kotlinx.serialization.Serializable @Serializable -data class ClueDto(val word: String, val count: Int) { +data class ClueDto(val word: String, val guessAmount: Int) { } \ No newline at end of file From fa68ceaf982fef837553892bdf401b0925fa0100 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:57:01 +0200 Subject: [PATCH 079/121] feat: add outgoing clue dto to match backend --- .../codenames/frontend/network/dto/ClueMessageDto.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt new file mode 100644 index 0000000..0548340 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt @@ -0,0 +1,12 @@ +package com.codenames.frontend.network.dto + +import com.codenames.frontend.data.model.enums.Team +import kotlinx.serialization.Serializable + +@Serializable +data class ClueMessageDto( + val lobbyCode: String, + val word: String, + val guessAmount: Int, + val currentTurn: Team +) \ No newline at end of file From a753ff40a1affd2910e3cb8620335d43c4173b09 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 00:57:19 +0200 Subject: [PATCH 080/121] fix: change game message dto to match backend --- .../main/java/com/codenames/frontend/network/dto/GameMessage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3667b99..957ffdd 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 @@ -11,7 +11,7 @@ data class GameMessage( val currentPhase: Role? = null, val currentRedFound: Int = 0, val currentBlueFound: Int = 0, - val currentClue: String? = null, + val currentClue: ClueDto? = null, val remainingGuesses: Int = 0, val cardList: List = emptyList(), ) From 7864ffeb72a5459fdf4fbe71520a0b4e4ef854d7 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:02:24 +0200 Subject: [PATCH 081/121] fix: send the clue message DTO that matches the backend --- .../network/websocket/GameWebSocketHandler.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 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 fe854bb..79113d0 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,9 @@ 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.ClueDto +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.WebSocketJoinMessage @@ -48,8 +50,17 @@ class GameWebSocketHandler session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } - suspend fun sendClue(lobbyCode: String, msg: ClueDto) { - session.convertAndSend("/app/submit-clue", msg, ClueDto.serializer()) + suspend fun sendClue(lobbyCode: String, + word: String, + guessAmount: Int, + currentTurn: Team) { + val msg = ClueMessageDto( + lobbyCode = lobbyCode, + word = word, + guessAmount = guessAmount, + currentTurn = currentTurn + ) + session.convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } From f09f5595790b38411528d3eea78d05053241bc4b Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:03:13 +0200 Subject: [PATCH 082/121] fix: change parameters to match the method that sends dto to backend --- .../java/com/codenames/frontend/viewmodel/GameViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 84701e7..92b5fc0 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -129,9 +129,10 @@ class GameViewModel } fun submitClue(lobbyCode: String, word: String, count: Int) { + val turn = uiState.value.currentTurn ?: return viewModelScope.launch { try { - client.sendClue(lobbyCode, ClueDto(word, count)) + client.sendClue(lobbyCode, word, count, turn) } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } From 64a606a98a085041223b6b081206e90dc0975763 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:10:34 +0200 Subject: [PATCH 083/121] test: fix miss-match that clue has to be DTO and not String --- .../com/codenames/frontend/viewmodel/GameViewModelTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 276f765..5b47c22 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -46,7 +46,7 @@ class GameViewModelTest { currentPhase = Role.OPERATIVE, 0, 0, - "", + null, 0, emptyList(), ) @@ -238,7 +238,7 @@ class GameViewModelTest { currentPhase = Role.OPERATIVE, currentRedFound = 1, currentBlueFound = 2, - currentClue = "EAGLE", + currentClue = ClueDto("EAGLE", 3), remainingGuesses = 3, cardList = listOf( @@ -268,7 +268,7 @@ class GameViewModelTest { advanceUntilIdle() coVerify { - client.sendClue(lobbyCode, ClueDto(word, count)) + client.sendClue(lobbyCode, word, count, Team.RED) } } From dbab5f04e20534964dcf81ab0b4db5c4999b5658 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:18:52 +0200 Subject: [PATCH 084/121] test: fix tests * we need to extract the word from the clue dto * we need to set the team so we can verify clue being sent --- .../java/com/codenames/frontend/viewmodel/GameViewModelTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 5b47c22..4deb008 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -253,7 +253,7 @@ class GameViewModelTest { assertEquals(Team.BLUE, state.currentTurn) assertEquals(Role.OPERATIVE, state.currentPhase) - assertEquals("EAGLE", state.currentClue) + assertEquals("EAGLE", state.currentClue?.word) assertEquals(3, state.remainingGuesses) assertEquals(2, state.cardList.size) assertEquals("BERLIN", state.cardList[0].word) @@ -263,6 +263,7 @@ class GameViewModelTest { fun testSubmitClue() = runTest { val word = "EAGLE" val count = 2 + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED)) viewModel.submitClue(lobbyCode, word, count) advanceUntilIdle() From 3bc9040cfba03bd0173781b46159015f5d59efa6 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:21:37 +0200 Subject: [PATCH 085/121] fix: add extraction if clue is available or pass standard string if null --- .../com/codenames/frontend/ui/screens/GameScreenWrapper.kt | 5 ++++- 1 file 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 267d483..6df6d9f 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 @@ -31,12 +31,15 @@ fun GameScreenWrapper( val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() val cards = gameState.cardList.map { it.toGameCard() } + val currentHintText = gameState.currentClue?.let { + "${it.word} ${it.guessAmount}" + } ?: "Waiting for hint..." GameboardScreen( userRole = effectiveRole, gameState = GameState( - currentHint = gameState.currentClue ?: "Waiting for hint...", + currentHint = currentHintText, currentTurn = gameState.currentTurn, currentPhase = gameState.currentPhase, winner = gameState.winner, From d82c6f9afc6f15a4ba8ff8685ff5ac31f7785ca0 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:23:26 +0200 Subject: [PATCH 086/121] refactor: remove TODO, can be asked in PR --- .../java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt | 1 - 1 file changed, 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 6df6d9f..0c4b8bc 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 @@ -54,7 +54,6 @@ fun GameScreenWrapper( if (lobbyCode.isNotBlank()) { gameViewModel.submitClue(lobbyCode, word, count) } - // TODO: Check if I implemented the correct endpoint in submitClue. }, onReveal = { // TODO: Send guess through GameViewModel once backend endpoint exists. From 5c05b0930e7f9f195dc9aac98d07df474ee8f7f6 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 01:25:02 +0200 Subject: [PATCH 087/121] refactor: execute ktlintFormat command --- .../codenames/frontend/network/dto/ClueDto.kt | 6 ++-- .../frontend/network/dto/ClueMessageDto.kt | 4 +-- .../network/websocket/GameWebSocketHandler.kt | 28 +++++++++---------- .../frontend/ui/screens/GameScreenWrapper.kt | 7 +++-- .../frontend/ui/screens/GameboardScreen.kt | 7 +++-- .../frontend/viewmodel/GameViewModel.kt | 7 +++-- .../frontend/viewmodel/GameViewModelTest.kt | 20 ++++++------- 7 files changed, 43 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt index 68adec8..262fed2 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt @@ -3,5 +3,7 @@ package com.codenames.frontend.network.dto import kotlinx.serialization.Serializable @Serializable -data class ClueDto(val word: String, val guessAmount: Int) { -} \ No newline at end of file +data class ClueDto( + val word: String, + val guessAmount: Int, +) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt index 0548340..c8b8084 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt @@ -8,5 +8,5 @@ data class ClueMessageDto( val lobbyCode: String, val word: String, val guessAmount: Int, - val currentTurn: Team -) \ No newline at end of file + val currentTurn: Team, +) 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 79113d0..b257ed3 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 @@ -2,7 +2,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.ClueDto import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage @@ -50,18 +49,19 @@ 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 - ) - session.convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) + suspend fun sendClue( + lobbyCode: String, + word: String, + guessAmount: Int, + currentTurn: Team, + ) { + val msg = + ClueMessageDto( + lobbyCode = lobbyCode, + word = word, + guessAmount = guessAmount, + currentTurn = currentTurn, + ) + session.convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } - - } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt index 0c4b8bc..113e8d5 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 @@ -31,9 +31,10 @@ fun GameScreenWrapper( val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() val cards = gameState.cardList.map { it.toGameCard() } - val currentHintText = gameState.currentClue?.let { - "${it.word} ${it.guessAmount}" - } ?: "Waiting for hint..." + val currentHintText = + gameState.currentClue?.let { + "${it.word} ${it.guessAmount}" + } ?: "Waiting for hint..." GameboardScreen( userRole = effectiveRole, 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 dca56fb..b453b59 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 @@ -597,7 +597,7 @@ fun HintSection( value = countInput, onValueChange = onCountChange, modifier = Modifier.width(80.dp), - state = AppTextFieldState(label = "COUNT", placeholder = "0") + state = AppTextFieldState(label = "COUNT", placeholder = "0"), ) AppButton( @@ -787,11 +787,12 @@ fun OfflineGameStateTestScreen() { currentRedFound = currentRedFound, cards = cards, chatMessages = chatMessages, - currentPhase = Role.OPERATIVE + currentPhase = Role.OPERATIVE, ), onHintChange = { word, count -> currentHint = word - remainingGuesses = count }, + remainingGuesses = count + }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, ) 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 92b5fc0..9c164e5 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -6,7 +6,6 @@ import com.codenames.frontend.data.model.ChatLists import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.repository.ChatRepository -import com.codenames.frontend.network.dto.ClueDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler @@ -128,7 +127,11 @@ class GameViewModel } } - fun submitClue(lobbyCode: String, word: String, count: Int) { + fun submitClue( + lobbyCode: String, + word: String, + count: Int, + ) { val turn = uiState.value.currentTurn ?: return viewModelScope.launch { try { 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 4deb008..2ee50b3 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -260,17 +260,17 @@ class GameViewModelTest { } @Test - fun testSubmitClue() = runTest { - val word = "EAGLE" - val count = 2 - viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED)) + fun testSubmitClue() = + runTest { + val word = "EAGLE" + val count = 2 + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED)) - viewModel.submitClue(lobbyCode, word, count) - advanceUntilIdle() + viewModel.submitClue(lobbyCode, word, count) + advanceUntilIdle() - coVerify { - client.sendClue(lobbyCode, word, count, Team.RED) + coVerify { + client.sendClue(lobbyCode, word, count, Team.RED) + } } - } - } From 7368a3f5b045fde1c92fc8e96f63053cedee95c6 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 10:32:49 +0200 Subject: [PATCH 088/121] feature: only host can start game --- .../codenames/frontend/network/provider/RetrofitProvider.kt | 2 +- .../frontend/network/websocket/GameWebSocketHandler.kt | 2 +- .../java/com/codenames/frontend/ui/screens/LobbyScreen.kt | 3 ++- .../java/com/codenames/frontend/viewmodel/LobbyViewModel.kt | 5 +++++ 4 files changed, 9 insertions(+), 3 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..c18e691 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://10.0.2.2: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 d1d993f..8cffa59 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 @@ -12,7 +12,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConver import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject -const val BASE_URL = "ws://localhost:8080/ws-fallback" +const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" class GameWebSocketHandler @Inject 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 1aeda01..78e0a1b 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 @@ -261,7 +261,8 @@ fun GameSettingsColumn( val canStart = usernameState.username.isNotBlank() && lobbyCode.isNotBlank() && - currentRole != PlayerRoles.NONE + currentRole != PlayerRoles.NONE && + viewModel.getIsHost(usernameState.username) Column( 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 e6a1f32..494205a 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -170,6 +170,11 @@ class LobbyViewModel } } + fun getIsHost(username: String) : Boolean { + val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false + return player.isHost + } + private fun cleanup() { _state.update { it.copy( From 58b656f74c1d9273e340e9d64296555c7a92e13c Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 10:49:40 +0200 Subject: [PATCH 089/121] refactor: extracted data classes and enums from GameBoard Screen --- .../codenames/frontend/data/model/GameCard.kt | 9 +++++++ .../frontend/data/model/GameState.kt | 14 ++++++++++ .../frontend/data/model/enums/CardType.kt | 8 ++++++ .../frontend/ui/screens/GameboardScreen.kt | 27 +++---------------- 4 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/GameCard.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/model/GameState.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/GameCard.kt b/app/src/main/java/com/codenames/frontend/data/model/GameCard.kt new file mode 100644 index 0000000..9a40a82 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/GameCard.kt @@ -0,0 +1,9 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.CardType + +data class GameCard( + val word: String, + val type: CardType, + val revealed: Boolean = false, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/GameState.kt b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt new file mode 100644 index 0000000..3e5817b --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt @@ -0,0 +1,14 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.ui.screens.GameCard + +data class GameState( + val currentHint: String, + val cards: List, + val currentTurn: String = "", + val winner: String? = null, + val remainingGuesses: Int = 0, + val currentRedFound: Int = 0, + val currentBlueFound: Int = 0, + val chatMessages: List = emptyList(), +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt new file mode 100644 index 0000000..c24b321 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt @@ -0,0 +1,8 @@ +package com.codenames.frontend.data.model.enums + +enum class CardType { + BLUE, + RED, + NEUTRAL, + ASSASSIN, +} \ No newline at end of file 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 4054721..6605b71 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 @@ -47,6 +47,9 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.codenames.frontend.data.model.ChatDomainModel import com.codenames.frontend.data.model.ChatMessage +import com.codenames.frontend.data.model.GameCard +import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton @@ -64,30 +67,6 @@ import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient import com.codenames.frontend.viewmodel.ChatViewModel -enum class CardType { - BLUE, - RED, - NEUTRAL, - ASSASSIN, -} - -data class GameCard( - val word: String, - val type: CardType, - val revealed: Boolean = false, -) - -data class GameState( - val currentHint: String, - val cards: List, - val currentTurn: String = "", - val winner: String? = null, - val remainingGuesses: Int = 0, - val currentRedFound: Int = 0, - val currentBlueFound: Int = 0, - val chatMessages: List = emptyList(), -) - @Suppress("ktlint:standard:function-naming") @Composable fun GameboardScreen( From 6e6bddafa1a326e48509f0198fef4983db7cb254 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 12:01:27 +0200 Subject: [PATCH 090/121] add game start flow --- .../frontend/data/model/GameState.kt | 4 +- .../data/repository/LobbyRepository.kt | 7 ++++ .../frontend/network/api/LobbyApi.kt | 5 +++ .../com/codenames/frontend/ui/UiMappers.kt | 4 +- .../frontend/ui/composables/GameBoardGrid.kt | 2 +- .../frontend/ui/screens/GameScreenWrapper.kt | 1 + .../frontend/ui/screens/LobbyScreen.kt | 38 +++++++++++++------ .../frontend/viewmodel/LobbyViewModel.kt | 28 ++++++++++++-- 8 files changed, 68 insertions(+), 21 deletions(-) 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 3e5817b..39ec755 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 @@ -1,7 +1,5 @@ package com.codenames.frontend.data.model -import com.codenames.frontend.ui.screens.GameCard - data class GameState( val currentHint: String, val cards: List, @@ -11,4 +9,4 @@ data class GameState( val currentRedFound: Int = 0, val currentBlueFound: Int = 0, val chatMessages: List = emptyList(), -) +) \ No newline at end of file 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 7ae6ee7..53c0cf5 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 @@ -35,4 +35,11 @@ class LobbyRepository val player = PlayerDto(username, role, team, false) return api.changeRole(lobbyCode, player) } + + suspend fun sendStartGame( + lobbyCode: String, + username: String + ): LobbyResponse { + return api.startGame(lobbyCode, username) + } } diff --git a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt index b72018d..0fa1a5c 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 @@ -37,4 +37,9 @@ interface LobbyApi { @Path("lobbyCode") lobbyCode: String, @Body playerDto: PlayerDto, ): LobbyResponse + + @GET("lobby/{lobbyCode}/start-game") + suspend fun startGame( + @Path("lobbyCode") lobbyCode: String, @Query("username") username: String + ): LobbyResponse } diff --git a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt index f66ffb0..6ff11fb 100644 --- a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt +++ b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt @@ -1,12 +1,12 @@ package com.codenames.frontend.ui +import com.codenames.frontend.data.model.GameCard import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.CardDto import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.screens.CardType -import com.codenames.frontend.ui.screens.GameCard fun CardDto.toGameCard(): GameCard = GameCard( 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 d3a4523..4fc7dea 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 @@ -11,8 +11,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp +import com.codenames.frontend.data.model.GameCard import com.codenames.frontend.ui.screens.CodenamesCard -import com.codenames.frontend.ui.screens.GameCard const val BOARD_COLUMNS = 5 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 273ec66..53f0538 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,6 +5,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.navigation.NavHostController +import com.codenames.frontend.data.model.GameState import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.toGameCard 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 78e0a1b..ef50f8d 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -28,6 +29,7 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.NavHostController import com.codenames.frontend.data.model.LobbyUiState +import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -59,22 +61,36 @@ fun LobbyScreen( val lobbyUiState by viewModel.state.collectAsState() val currentPlayer = lobbyUiState.players.firstOrNull { it.name == usernameState.username } val currentRole = currentPlayer?.toPlayerRole() ?: PlayerRoles.NONE + val connectionState by gameViewModel.connectionState.collectAsState() val onStartGame = { - val lobbyCode = lobbyUiState.lobbyCode.orEmpty() - val teamAndRole = currentRole.toTeamAndRole() + viewModel.sendStartGame(usernameState.username) + } - if (lobbyCode.isNotBlank() && teamAndRole != null) { - val (team, role) = teamAndRole + LaunchedEffect(lobbyUiState.isGameStarted) { + if(lobbyUiState.isGameStarted) { - gameViewModel.connect( - username = usernameState.username, - lobbyCode = lobbyCode, - team = team.name, - role = role.name, - ) + val lobbyCode = lobbyUiState.lobbyCode.orEmpty() + val teamAndRole = currentRole.toTeamAndRole() + + if (lobbyCode.isNotBlank() && teamAndRole != null) { + val (team, role) = teamAndRole + + gameViewModel.connect( + username = usernameState.username, + lobbyCode = lobbyCode, + team = team.name, + role = role.name, + ) + + + } + } + } - navController.navigate("${Screen.Gameboard.route}/${usernameState.username}/${currentRole.name}") + LaunchedEffect(connectionState) { + if(connectionState == ConnectionState.CONNECTED) { + navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") } } 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 494205a..9f75ede 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -170,10 +170,30 @@ class LobbyViewModel } } - fun getIsHost(username: String) : Boolean { - val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false - return player.isHost - } + fun getIsHost(username: String) : Boolean { + val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false + return player.isHost + } + + fun sendStartGame(username: String) { + val lobbyCode = _state.value.lobbyCode.orEmpty() + if(!username.isBlank() && !lobbyCode.isEmpty() && getIsHost(username)) { + viewModelScope.launch { + try { + setLoading(true) + val response = repository.sendStartGame(lobbyCode, username) + _state.update { + response.toLobbyState() + } + updateUiState(_state.value.players) + } catch (e: Exception) { + setError(e.message) + } finally { + setLoading(false) + } + } + } + } private fun cleanup() { _state.update { From 83c68086601ac01ce973ef105e121f49d46049d5 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sun, 17 May 2026 14:43:58 +0200 Subject: [PATCH 091/121] test: add test for sendClue --- .../websocket/GameWebSocketHandlerTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 473a6ea..9367d52 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,8 @@ 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.GuessMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage @@ -138,4 +140,31 @@ class GameWebSocketHandlerTest { coVerify { session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } } + + @Test + fun testSendClue() = + runTest { + val lobbyCode = "LOBBY123" + val word = "clueWord" + val guessAmount = 2 + val currentTurn = Team.RED + + wsClient.sendClue(lobbyCode, word, guessAmount, currentTurn) + + val expectedMsg = + ClueMessageDto( + lobbyCode = lobbyCode, + word = word, + guessAmount = guessAmount, + currentTurn = currentTurn, + ) + + coVerify { + session.convertAndSend( + "/app/submit-clue", + expectedMsg, + ClueMessageDto.serializer(), + ) + } + } } From e3107a261c78ce6876ccbf61fc3f42595ac79f6c Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 15:52:16 +0200 Subject: [PATCH 092/121] progress: game start button works, all players in the lobby are transferred to game screen --- .../codenames/frontend/data/model/Mapper.kt | 1 + .../data/repository/GameRepository.kt | 15 ++++++++++ .../frontend/network/dto/GameMessage.kt | 1 + .../frontend/network/dto/LobbyResponse.kt | 1 + .../frontend/network/dto/StartGameMessage.kt | 6 ++++ .../network/websocket/GameWebSocketHandler.kt | 11 ++++++- .../frontend/ui/screens/LobbyScreen.kt | 8 +++-- .../frontend/viewmodel/GameViewModel.kt | 30 +++++++++++++++++-- .../frontend/viewmodel/LobbyViewModel.kt | 2 ++ .../websocket/GameWebSocketHandlerTest.kt | 4 +-- .../frontend/viewmodel/GameViewModelTest.kt | 4 +-- 11 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt create mode 100644 app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt index 901c924..a8acfb1 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 @@ -7,6 +7,7 @@ fun LobbyResponse.toLobbyState(): LobbyUiState = LobbyUiState( lobbyCode = lobbyCode, players = playerList.map { it.toUi() }, + isGameStarted = isStarted ) fun PlayerDto.toUi(): Player = diff --git a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt new file mode 100644 index 0000000..0b80140 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -0,0 +1,15 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.network.dto.StartGameMessage +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import javax.inject.Inject + +class GameRepository +@Inject constructor( + private val webSocketHandler: GameWebSocketHandler, +){ + suspend fun startGame(lobbyCode: String) { + val msg = StartGameMessage(lobbyCode) + webSocketHandler.startGame(msg) + } +} \ No newline at end of file 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 d0acd9e..7fe8ca7 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 @@ -11,4 +11,5 @@ data class GameMessage( val currentClue: String? = null, val remainingGuesses: Int = 0, val cardList: List = emptyList(), + val error: String? = null ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt b/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt index 44dac6a..0365a1f 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable data class LobbyResponse( val lobbyCode: String, val playerList: List, + val isStarted: Boolean ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt new file mode 100644 index 0000000..c93fcf2 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt @@ -0,0 +1,6 @@ +package com.codenames.frontend.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class StartGameMessage(val lobbyCode: String) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt index 8cffa59..739b3dc 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,8 +1,10 @@ package com.codenames.frontend.network.websocket +import android.util.Log import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage +import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import kotlinx.coroutines.flow.Flow import org.hildan.krossbow.stomp.StompClient @@ -11,9 +13,11 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject +import javax.inject.Singleton const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" +@Singleton class GameWebSocketHandler @Inject constructor( @@ -23,6 +27,11 @@ class GameWebSocketHandler suspend fun connectStomp() { session = client.connect(BASE_URL).withJsonConversions() + Log.d("WebSocket", "Connected to Websocket, session: $session") + } + + suspend fun startGame(msg: StartGameMessage) { + session.convertAndSend("/topic/game/start-game", msg, StartGameMessage.serializer() ) } suspend fun sendGuess(msg: GuessMessage) { @@ -33,7 +42,7 @@ class GameWebSocketHandler suspend fun subscribeToLobby(lobbyCode: String): Flow = session.subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) - suspend fun registerWebSocketSession(msg: WebSocketJoinMessage) { + suspend fun sendReconnectMessage(msg: WebSocketJoinMessage) { session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } 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 ef50f8d..424535e 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -73,6 +74,8 @@ fun LobbyScreen( val lobbyCode = lobbyUiState.lobbyCode.orEmpty() val teamAndRole = currentRole.toTeamAndRole() + Log.d("LobbyScreen", "Lobby UI state is started, recomposing") + if (lobbyCode.isNotBlank() && teamAndRole != null) { val (team, role) = teamAndRole @@ -81,16 +84,15 @@ fun LobbyScreen( lobbyCode = lobbyCode, team = team.name, role = role.name, + isHost = viewModel.getIsHost(usernameState.username) ) - - } } } LaunchedEffect(connectionState) { if(connectionState == ConnectionState.CONNECTED) { - navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") + navController.navigate(Screen.Gameboard.route) } } 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 05269db..866b3d9 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -1,13 +1,14 @@ 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.enums.ConnectionState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.repository.ChatRepository +import com.codenames.frontend.data.repository.GameRepository import com.codenames.frontend.network.dto.GameMessage -import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -23,6 +24,7 @@ class GameViewModel constructor( private val client: GameWebSocketHandler, private val chatRepository: ChatRepository, + private val gameRepository: GameRepository ) : ViewModel() { private var job: Job? = null @@ -43,6 +45,7 @@ class GameViewModel lobbyCode: String, team: String, role: String, + isHost: Boolean = false ) { job?.cancel() @@ -53,6 +56,8 @@ class GameViewModel try { client.connectStomp() + Log.d("GameViewModel", "Connection successful") + _connectionState.value = ConnectionState.CONNECTED launch { @@ -61,6 +66,8 @@ class GameViewModel .collect { handleMessage(it) } } + Log.d("GameViewModel", "Subscribed to Lobby") + launch { // msg is the domain model chat we emit in the ChatRepository chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> @@ -88,13 +95,26 @@ class GameViewModel } } - client.registerWebSocketSession(WebSocketJoinMessage(username, lobbyCode)) + if(isHost) { + gameRepository.startGame(lobbyCode) + } + } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } } } + fun sendGameStart(lobbyCode: String) { + if(lobbyCode.isBlank()){ + setError("Could not send game start, lobby Code is blank") + return + } + viewModelScope.launch { + gameRepository.startGame(lobbyCode) + } + } + fun sendLobbyMessage( lobbyCode: String, username: String, @@ -131,4 +151,10 @@ class GameViewModel _uiState.value = message // Add logic to handle incoming messages } + + private fun setError(msg: String) { + _uiState.update { + it.copy(error = msg) + } + } } 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 9f75ede..d57543b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.LobbyUiState @@ -189,6 +190,7 @@ class LobbyViewModel } catch (e: Exception) { setError(e.message) } finally { + Log.d("LobbyViewModel", "Game start sent. Current state: ${_state.value}") setLoading(false) } } 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 473a6ea..c3ac28b 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 @@ -96,7 +96,7 @@ class GameWebSocketHandlerTest { } @Test - fun testRegisterWebSocketSessionSendsMessage() = + fun testSendReconnectMessageSendsMessage() = runTest { val session = mockk(relaxed = true) val client = mockk() @@ -106,7 +106,7 @@ class GameWebSocketHandlerTest { val msg = WebSocketJoinMessage("name", "1234") - wsClient.registerWebSocketSession(msg) + wsClient.sendReconnectMessage(msg) coVerify { session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) 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 313caa7..b8ada77 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -111,13 +111,13 @@ class GameViewModelTest { coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - coEvery { client.registerWebSocketSession(any()) } just Runs + coEvery { client.sendReconnectMessage(any()) } just Runs viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() - coVerify { client.registerWebSocketSession(WebSocketJoinMessage(username, lobbyCode)) } + coVerify { client.sendReconnectMessage(WebSocketJoinMessage(username, lobbyCode)) } } @Test From 2c35e5d053e533296b70c4d88080583ce92ab2d4 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 19:34:53 +0200 Subject: [PATCH 093/121] minor mapping changes to ensure game start works: - changed color type from String to Color in CardDto - removed current found parameter from game state - current found is now computed in game view model - also removed unused chat messages parameter from game state - now the flow that the game view model emits is really a game state (no longer the game message) - corrected a wrong websocket endpoint - added some mapping functions --- .../frontend/data/model/GameState.kt | 7 ++-- .../codenames/frontend/data/model/Mapper.kt | 19 +++++++++++ .../codenames/frontend/network/dto/CardDto.kt | 3 +- .../frontend/network/dto/GameMessage.kt | 4 ++- .../network/websocket/GameWebSocketHandler.kt | 2 +- .../com/codenames/frontend/ui/UiMappers.kt | 11 +----- .../frontend/ui/navigation/NavGraph.kt | 4 ++- .../frontend/ui/screens/GameScreenWrapper.kt | 11 +++--- .../frontend/ui/screens/GameboardScreen.kt | 15 ++++---- .../frontend/viewmodel/GameViewModel.kt | 34 +++++++++++++------ 10 files changed, 68 insertions(+), 42 deletions(-) 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 39ec755..3427f3b 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 @@ -1,12 +1,9 @@ package com.codenames.frontend.data.model data class GameState( - val currentHint: String, - val cards: List, + val currentHint: String = "", + val cards: List = emptyList(), val currentTurn: String = "", val winner: String? = null, val remainingGuesses: Int = 0, - val currentRedFound: Int = 0, - val currentBlueFound: Int = 0, - val chatMessages: List = emptyList(), ) \ No newline at end of file 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 a8acfb1..c69cc55 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt @@ -1,5 +1,7 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto @@ -18,3 +20,20 @@ fun PlayerDto.toUi(): Player = isHost = isHost, isReady = false, // if we add this functionality ) + +fun GameMessage.toGameState(): GameState = + GameState( + currentHint = currentClue ?: "", + cards = cardList.map { it.toGameCard() }, + currentTurn = currentTurn, + winner = winner, + remainingGuesses = remainingGuesses + ) + + +fun CardDto.toGameCard(): GameCard = + GameCard( + word = word, + type = color, + revealed = isGuessed + ) \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt index 4fba2a2..5506712 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt @@ -1,10 +1,11 @@ package com.codenames.frontend.network.dto +import com.codenames.frontend.data.model.enums.CardType import kotlinx.serialization.Serializable @Serializable data class CardDto( val word: String, - val color: String, + val color: CardType, val isGuessed: Boolean, ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt index 7fe8ca7..35103b4 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt @@ -1,5 +1,7 @@ package com.codenames.frontend.network.dto +import androidx.compose.runtime.currentComposer +import com.codenames.frontend.data.model.GameState import kotlinx.serialization.Serializable @Serializable @@ -12,4 +14,4 @@ data class GameMessage( val remainingGuesses: Int = 0, val cardList: List = emptyList(), val error: String? = null -) +) \ No newline at end of file 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 739b3dc..7961bca 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 @@ -31,7 +31,7 @@ class GameWebSocketHandler } suspend fun startGame(msg: StartGameMessage) { - session.convertAndSend("/topic/game/start-game", msg, StartGameMessage.serializer() ) + session.convertAndSend("/app/start-game", msg, StartGameMessage.serializer() ) } suspend fun sendGuess(msg: GuessMessage) { diff --git a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt index 6ff11fb..75575d1 100644 --- a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt +++ b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt @@ -11,16 +11,7 @@ import com.codenames.frontend.ui.roles.PlayerRoles fun CardDto.toGameCard(): GameCard = GameCard( word = word, - type = - when (color.uppercase()) { - "BLUE" -> CardType.BLUE - "RED" -> CardType.RED - "BLACK" -> CardType.ASSASSIN - "ASSASSIN" -> CardType.ASSASSIN - "WHITE" -> CardType.NEUTRAL - "NEUTRAL" -> CardType.NEUTRAL - else -> CardType.NEUTRAL - }, + type = color, revealed = isGuessed, ) 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 f04ce85..701ecf6 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 @@ -83,7 +83,9 @@ fun NavGraph( } composable("game_test") { - OfflineGameStateTestScreen() + OfflineGameStateTestScreen( + gameViewModel + ) } } } 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 53f0538..c651800 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 @@ -3,12 +3,11 @@ package com.codenames.frontend.ui.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.codenames.frontend.data.model.GameState import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.toGameCard import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -31,20 +30,17 @@ fun GameScreenWrapper( val effectiveRole = lobbyViewModel.getRoleForUser(usernameState.username) val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() - val cards = gameState.cardList.map { it.toGameCard() } + val cards = gameState.cards GameboardScreen( userRole = effectiveRole, gameState = GameState( - currentHint = gameState.currentClue ?: "Waiting for hint...", + currentHint = gameState.currentHint, currentTurn = gameState.currentTurn, winner = gameState.winner, remainingGuesses = gameState.remainingGuesses, - currentRedFound = gameState.currentRedFound, - currentBlueFound = gameState.currentBlueFound, cards = cards, - chatMessages = chatState.teamMessages, ), onHintChange = { // TODO: Send clue through GameViewModel once backend endpoint exists. @@ -65,5 +61,6 @@ fun GameScreenWrapper( onSettingsClick = { navController.navigate(Screen.Settings.route) }, + gameViewModel = gameViewModel ) } 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 6605b71..8309d1d 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 @@ -66,6 +66,7 @@ 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.viewmodel.ChatViewModel +import com.codenames.frontend.viewmodel.GameViewModel @Suppress("ktlint:standard:function-naming") @Composable @@ -78,14 +79,15 @@ fun GameboardScreen( onSendChatMessage: (String) -> Unit = {}, onSettingsClick: (() -> Unit)? = null, chatViewModel: ChatViewModel = viewModel(), + gameViewModel: GameViewModel ) { val currentHint = gameState.currentHint val cards = gameState.cards val currentTurn = gameState.currentTurn val winner = gameState.winner val remainingGuesses = gameState.remainingGuesses - val currentRedFound = gameState.currentRedFound - val currentBlueFound = gameState.currentBlueFound + val currentRedFound = gameViewModel.getCurrentFound(CardType.RED) + val currentBlueFound = gameViewModel.getCurrentFound(CardType.BLUE) val chatUiState by chatViewModel.uiState.collectAsState() var hintInput by rememberSaveable { mutableStateOf("") } @@ -665,13 +667,16 @@ fun getColor(type: CardType): Color = @Suppress("ktlint:standard:function-naming") @Composable -fun OfflineGameStateTestScreen() { +fun OfflineGameStateTestScreen( + gameViewModel: GameViewModel +) { var currentHint by rememberSaveable { mutableStateOf("EAGLE") } var currentTurn by rememberSaveable { mutableStateOf("BLUE") } var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } var currentBlueFound by rememberSaveable { mutableIntStateOf(0) } var currentRedFound by rememberSaveable { mutableIntStateOf(0) } + val cards = remember { mutableStateListOf( @@ -743,13 +748,11 @@ fun OfflineGameStateTestScreen() { currentHint = currentHint, currentTurn = currentTurn, remainingGuesses = remainingGuesses, - currentBlueFound = currentBlueFound, - currentRedFound = currentRedFound, cards = cards, - chatMessages = chatMessages, ), onHintChange = { currentHint = it }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, + gameViewModel = gameViewModel ) } 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 866b3d9..ba0fcc8 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -1,17 +1,23 @@ package com.codenames.frontend.viewmodel import android.util.Log +import androidx.compose.ui.graphics.Color 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.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 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -28,8 +34,8 @@ class GameViewModel ) : ViewModel() { private var job: Job? = null - private val _uiState = MutableStateFlow(GameMessage()) - val uiState: StateFlow = _uiState + private val _uiState = MutableStateFlow(GameState()) + val uiState: StateFlow = _uiState // _chatState is mutable and should only be used by view model private val _chatState = MutableStateFlow(ChatLists()) @@ -96,7 +102,8 @@ class GameViewModel } if(isHost) { - gameRepository.startGame(lobbyCode) + delay(2000) + sendGameStart(lobbyCode) } } catch (e: Exception) { @@ -105,9 +112,8 @@ class GameViewModel } } - fun sendGameStart(lobbyCode: String) { + private fun sendGameStart(lobbyCode: String) { if(lobbyCode.isBlank()){ - setError("Could not send game start, lobby Code is blank") return } viewModelScope.launch { @@ -148,13 +154,21 @@ class GameViewModel } fun handleMessage(message: GameMessage) { - _uiState.value = message - // Add logic to handle incoming messages + val state = message.toGameState() + _uiState.update { + state + } + Log.d("GameViewModel", "Updated game state: $state") } - private fun setError(msg: String) { - _uiState.update { - it.copy(error = msg) + fun getCurrentFound(team: CardType): Int { + val cards = _uiState.value.cards + var count = 0 + for(card in cards) { + if(card.type == team && card.revealed) { + count++ + } } + return count } } From 1e52796214faf8a14fa166fb33e6601435694495 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 20:14:06 +0200 Subject: [PATCH 094/121] refactor: fix minor issues --- .../com/codenames/frontend/network/dto/GameMessage.kt | 2 -- app/src/main/java/com/codenames/frontend/ui/UiMappers.kt | 1 - .../java/com/codenames/frontend/ui/navigation/NavGraph.kt | 6 ------ .../java/com/codenames/frontend/ui/navigation/Screen.kt | 2 -- .../codenames/frontend/ui/screens/GameScreenWrapper.kt | 8 ++------ .../com/codenames/frontend/ui/screens/GameboardScreen.kt | 2 +- .../ui/{navigation => screens}/ScreenOrientation.kt | 0 .../com/codenames/frontend/viewmodel/GameViewModel.kt | 2 -- 8 files changed, 3 insertions(+), 20 deletions(-) rename app/src/main/java/com/codenames/frontend/ui/{navigation => screens}/ScreenOrientation.kt (100%) 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 35103b4..7e8a261 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt @@ -1,7 +1,5 @@ package com.codenames.frontend.network.dto -import androidx.compose.runtime.currentComposer -import com.codenames.frontend.data.model.GameState import kotlinx.serialization.Serializable @Serializable diff --git a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt index 75575d1..d0e0bde 100644 --- a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt +++ b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt @@ -2,7 +2,6 @@ package com.codenames.frontend.ui import com.codenames.frontend.data.model.GameCard import com.codenames.frontend.data.model.Player -import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.CardDto 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 701ecf6..08cbc09 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,8 +1,6 @@ package com.codenames.frontend.ui.navigation import androidx.compose.runtime.Composable -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 @@ -27,7 +25,6 @@ fun NavGraph( gameViewModel: GameViewModel = hiltViewModel(), ) { val navController = rememberNavController() - val usernameState by sessionViewModel.username.collectAsState() NavHost( navController = navController, @@ -63,11 +60,8 @@ fun NavGraph( composable( route = Screen.Gameboard.route, ) { - val currentRole = lobbyViewModel.getRoleForUser(usernameState.username) - GameScreenWrapper( navController = navController, - userRole = currentRole, lobbyViewModel = lobbyViewModel, gameViewModel = gameViewModel, sessionViewModel = sessionViewModel, diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt index 33464cc..05f3c39 100644 --- a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt @@ -16,6 +16,4 @@ sealed class Screen( object Settings : Screen("settings") object Username : Screen("username") - - object GameTest : Screen("game_test") } 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 c651800..bf63154 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 @@ -3,11 +3,9 @@ package com.codenames.frontend.ui.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.codenames.frontend.data.model.GameState import com.codenames.frontend.ui.navigation.Screen -import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -16,24 +14,22 @@ import com.codenames.frontend.viewmodel.SessionViewModel @Suppress("ktlint:standard:function-naming") fun GameScreenWrapper( navController: NavHostController, - userRole: PlayerRoles, lobbyViewModel: LobbyViewModel, gameViewModel: GameViewModel, sessionViewModel: SessionViewModel, ) { val lobbyState by lobbyViewModel.state.collectAsState() val gameState by gameViewModel.uiState.collectAsState() - val chatState by gameViewModel.chatState.collectAsState() val usernameState by sessionViewModel.username.collectAsState() + val userRole = lobbyViewModel.getRoleForUser(usernameState.username) val currentPlayer = lobbyState.players.firstOrNull { it.name == usernameState.username } - val effectiveRole = lobbyViewModel.getRoleForUser(usernameState.username) val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() val cards = gameState.cards GameboardScreen( - userRole = effectiveRole, + userRole = userRole, gameState = GameState( currentHint = gameState.currentHint, 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 8309d1d..86157e5 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 @@ -750,7 +750,7 @@ fun OfflineGameStateTestScreen( remainingGuesses = remainingGuesses, cards = cards, ), - onHintChange = { currentHint = it }, + onHintChange = { /* not necessary for offline test screen */ }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, gameViewModel = gameViewModel diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/ScreenOrientation.kt b/app/src/main/java/com/codenames/frontend/ui/screens/ScreenOrientation.kt similarity index 100% rename from app/src/main/java/com/codenames/frontend/ui/navigation/ScreenOrientation.kt rename to app/src/main/java/com/codenames/frontend/ui/screens/ScreenOrientation.kt diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt index ba0fcc8..53b6f33 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -1,7 +1,6 @@ package com.codenames.frontend.viewmodel import android.util.Log -import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.ChatLists @@ -9,7 +8,6 @@ import com.codenames.frontend.data.model.GameState import com.codenames.frontend.data.model.enums.CardType 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 From a2b8878a7076db831bc6f6c47f86d4aed9228374 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 20:14:52 +0200 Subject: [PATCH 095/121] format: reformatted via ktlint --- .../codenames/frontend/data/model/GameState.kt | 2 +- .../com/codenames/frontend/data/model/Mapper.kt | 9 ++++----- .../frontend/data/model/enums/CardType.kt | 2 +- .../frontend/data/repository/GameRepository.kt | 15 ++++++++------- .../frontend/data/repository/LobbyRepository.kt | 6 ++---- .../codenames/frontend/network/api/LobbyApi.kt | 3 ++- .../codenames/frontend/network/dto/GameMessage.kt | 4 ++-- .../frontend/network/dto/LobbyResponse.kt | 2 +- .../frontend/network/dto/StartGameMessage.kt | 4 +++- .../network/websocket/GameWebSocketHandler.kt | 2 +- .../codenames/frontend/ui/navigation/NavGraph.kt | 2 +- .../frontend/ui/screens/GameScreenWrapper.kt | 2 +- .../frontend/ui/screens/GameboardScreen.kt | 9 +++------ .../codenames/frontend/ui/screens/LobbyScreen.kt | 9 ++++----- .../codenames/frontend/viewmodel/GameViewModel.kt | 13 ++++++------- .../frontend/viewmodel/LobbyViewModel.kt | 6 +++--- 16 files changed, 43 insertions(+), 47 deletions(-) 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 3427f3b..b7217fd 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 @@ -6,4 +6,4 @@ data class GameState( val currentTurn: String = "", val winner: String? = null, val remainingGuesses: Int = 0, -) \ No newline at end of file +) 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 c69cc55..27c8173 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 @@ -9,7 +9,7 @@ fun LobbyResponse.toLobbyState(): LobbyUiState = LobbyUiState( lobbyCode = lobbyCode, players = playerList.map { it.toUi() }, - isGameStarted = isStarted + isGameStarted = isStarted, ) fun PlayerDto.toUi(): Player = @@ -27,13 +27,12 @@ fun GameMessage.toGameState(): GameState = cards = cardList.map { it.toGameCard() }, currentTurn = currentTurn, winner = winner, - remainingGuesses = remainingGuesses + remainingGuesses = remainingGuesses, ) - fun CardDto.toGameCard(): GameCard = GameCard( word = word, type = color, - revealed = isGuessed - ) \ No newline at end of file + revealed = isGuessed, + ) diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt index c24b321..52faca6 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt @@ -5,4 +5,4 @@ enum class CardType { RED, NEUTRAL, ASSASSIN, -} \ No newline at end of file +} 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 0b80140..0fc6887 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 @@ -5,11 +5,12 @@ import com.codenames.frontend.network.websocket.GameWebSocketHandler import javax.inject.Inject class GameRepository -@Inject constructor( - private val webSocketHandler: GameWebSocketHandler, -){ - suspend fun startGame(lobbyCode: String) { - val msg = StartGameMessage(lobbyCode) - webSocketHandler.startGame(msg) + @Inject + constructor( + private val webSocketHandler: GameWebSocketHandler, + ) { + suspend fun startGame(lobbyCode: String) { + val msg = StartGameMessage(lobbyCode) + webSocketHandler.startGame(msg) + } } -} \ No newline at end of file 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 53c0cf5..fd0f9f6 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/LobbyRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/LobbyRepository.kt @@ -38,8 +38,6 @@ class LobbyRepository suspend fun sendStartGame( lobbyCode: String, - username: String - ): LobbyResponse { - return api.startGame(lobbyCode, username) - } + username: String, + ): LobbyResponse = api.startGame(lobbyCode, username) } diff --git a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt index 0fa1a5c..ccfb313 100644 --- a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt +++ b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt @@ -40,6 +40,7 @@ interface LobbyApi { @GET("lobby/{lobbyCode}/start-game") suspend fun startGame( - @Path("lobbyCode") lobbyCode: String, @Query("username") username: String + @Path("lobbyCode") lobbyCode: String, + @Query("username") username: String, ): LobbyResponse } 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 7e8a261..7ef02a6 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 @@ -11,5 +11,5 @@ data class GameMessage( val currentClue: String? = null, val remainingGuesses: Int = 0, val cardList: List = emptyList(), - val error: String? = null -) \ No newline at end of file + val error: String? = null, +) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt b/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt index 0365a1f..abd147d 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt @@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable data class LobbyResponse( val lobbyCode: String, val playerList: List, - val isStarted: Boolean + val isStarted: Boolean, ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt index c93fcf2..bfd2e50 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt @@ -3,4 +3,6 @@ package com.codenames.frontend.network.dto import kotlinx.serialization.Serializable @Serializable -data class StartGameMessage(val lobbyCode: String) +data class StartGameMessage( + val lobbyCode: String, +) 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 7961bca..e686200 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 @@ -31,7 +31,7 @@ class GameWebSocketHandler } suspend fun startGame(msg: StartGameMessage) { - session.convertAndSend("/app/start-game", msg, StartGameMessage.serializer() ) + session.convertAndSend("/app/start-game", msg, StartGameMessage.serializer()) } suspend fun sendGuess(msg: GuessMessage) { 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 08cbc09..f9d74b3 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 @@ -78,7 +78,7 @@ fun NavGraph( composable("game_test") { OfflineGameStateTestScreen( - gameViewModel + gameViewModel, ) } } 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 bf63154..b9b778d 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 @@ -57,6 +57,6 @@ fun GameScreenWrapper( onSettingsClick = { navController.navigate(Screen.Settings.route) }, - gameViewModel = gameViewModel + gameViewModel = gameViewModel, ) } 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 86157e5..5ed63d2 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 @@ -79,7 +79,7 @@ fun GameboardScreen( onSendChatMessage: (String) -> Unit = {}, onSettingsClick: (() -> Unit)? = null, chatViewModel: ChatViewModel = viewModel(), - gameViewModel: GameViewModel + gameViewModel: GameViewModel, ) { val currentHint = gameState.currentHint val cards = gameState.cards @@ -667,16 +667,13 @@ fun getColor(type: CardType): Color = @Suppress("ktlint:standard:function-naming") @Composable -fun OfflineGameStateTestScreen( - gameViewModel: GameViewModel -) { +fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { var currentHint by rememberSaveable { mutableStateOf("EAGLE") } var currentTurn by rememberSaveable { mutableStateOf("BLUE") } var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } var currentBlueFound by rememberSaveable { mutableIntStateOf(0) } var currentRedFound by rememberSaveable { mutableIntStateOf(0) } - val cards = remember { mutableStateListOf( @@ -753,6 +750,6 @@ fun OfflineGameStateTestScreen( onHintChange = { /* not necessary for offline test screen */ }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, - gameViewModel = gameViewModel + gameViewModel = gameViewModel, ) } 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 424535e..9eb2cc3 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt @@ -69,8 +69,7 @@ fun LobbyScreen( } LaunchedEffect(lobbyUiState.isGameStarted) { - if(lobbyUiState.isGameStarted) { - + if (lobbyUiState.isGameStarted) { val lobbyCode = lobbyUiState.lobbyCode.orEmpty() val teamAndRole = currentRole.toTeamAndRole() @@ -84,14 +83,14 @@ fun LobbyScreen( lobbyCode = lobbyCode, team = team.name, role = role.name, - isHost = viewModel.getIsHost(usernameState.username) + isHost = viewModel.getIsHost(usernameState.username), ) } } } LaunchedEffect(connectionState) { - if(connectionState == ConnectionState.CONNECTED) { + if (connectionState == ConnectionState.CONNECTED) { navController.navigate(Screen.Gameboard.route) } } @@ -280,7 +279,7 @@ fun GameSettingsColumn( usernameState.username.isNotBlank() && lobbyCode.isNotBlank() && currentRole != PlayerRoles.NONE && - viewModel.getIsHost(usernameState.username) + viewModel.getIsHost(usernameState.username) Column( modifier = modifier, 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 53b6f33..204b2df 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -28,7 +28,7 @@ class GameViewModel constructor( private val client: GameWebSocketHandler, private val chatRepository: ChatRepository, - private val gameRepository: GameRepository + private val gameRepository: GameRepository, ) : ViewModel() { private var job: Job? = null @@ -49,7 +49,7 @@ class GameViewModel lobbyCode: String, team: String, role: String, - isHost: Boolean = false + isHost: Boolean = false, ) { job?.cancel() @@ -99,11 +99,10 @@ class GameViewModel } } - if(isHost) { + if (isHost) { delay(2000) sendGameStart(lobbyCode) } - } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } @@ -111,7 +110,7 @@ class GameViewModel } private fun sendGameStart(lobbyCode: String) { - if(lobbyCode.isBlank()){ + if (lobbyCode.isBlank()) { return } viewModelScope.launch { @@ -162,8 +161,8 @@ class GameViewModel fun getCurrentFound(team: CardType): Int { val cards = _uiState.value.cards var count = 0 - for(card in cards) { - if(card.type == team && card.revealed) { + for (card in cards) { + if (card.type == team && card.revealed) { count++ } } diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt index d57543b..214f077 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -171,14 +171,14 @@ class LobbyViewModel } } - fun getIsHost(username: String) : Boolean { + fun getIsHost(username: String): Boolean { val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false return player.isHost } fun sendStartGame(username: String) { val lobbyCode = _state.value.lobbyCode.orEmpty() - if(!username.isBlank() && !lobbyCode.isEmpty() && getIsHost(username)) { + if (!username.isBlank() && !lobbyCode.isEmpty() && getIsHost(username)) { viewModelScope.launch { try { setLoading(true) @@ -188,7 +188,7 @@ class LobbyViewModel } updateUiState(_state.value.players) } catch (e: Exception) { - setError(e.message) + setError(e.message) } finally { Log.d("LobbyViewModel", "Game start sent. Current state: ${_state.value}") setLoading(false) From fb750e72c16cfa1f8e4fcd56ac1958f1dd764ba9 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 21:20:29 +0200 Subject: [PATCH 096/121] fix: some test changes and more type changes, commit for device change --- .../frontend/data/model/GameState.kt | 7 +- .../codenames/frontend/data/model/Mapper.kt | 14 +++- .../frontend/network/dto/GameMessage.kt | 9 ++- .../frontend/ui/screens/GameboardScreen.kt | 14 ++-- .../com/codenames/frontend/UiMappersTest.kt | 10 +-- .../data/repository/LobbyRepositoryTest.kt | 9 ++- .../websocket/GameWebSocketHandlerTest.kt | 9 +++ .../frontend/viewmodel/GameViewModelTest.kt | 76 +++++++++++-------- .../frontend/viewmodel/LobbyViewModelTest.kt | 20 ++++- 9 files changed, 111 insertions(+), 57 deletions(-) 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 b7217fd..246820a 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 @@ -1,9 +1,12 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.ui.roles.PlayerRoles + data class GameState( val currentHint: String = "", val cards: List = emptyList(), - val currentTurn: String = "", - val winner: String? = null, + val currentTurn: PlayerRoles = PlayerRoles.NONE, + val winner: Team? = null, val remainingGuesses: Int = 0, ) 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 27c8173..d870304 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt @@ -1,9 +1,12 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.CardDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto +import com.codenames.frontend.ui.roles.PlayerRoles fun LobbyResponse.toLobbyState(): LobbyUiState = LobbyUiState( @@ -25,7 +28,7 @@ fun GameMessage.toGameState(): GameState = GameState( currentHint = currentClue ?: "", cards = cardList.map { it.toGameCard() }, - currentTurn = currentTurn, + currentTurn = getCurrentTurn(), winner = winner, remainingGuesses = remainingGuesses, ) @@ -36,3 +39,12 @@ fun CardDto.toGameCard(): GameCard = type = color, revealed = isGuessed, ) + +fun GameMessage.getCurrentTurn() : PlayerRoles { + if(currentTurn == Team.RED) { + if (currentPhase == Role.SPYMASTER) return PlayerRoles.RED_SPYMASTER + return PlayerRoles.RED_OPERATIVE + } + if(currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER + return PlayerRoles.BLUE_OPERATIVE +} diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt index 7ef02a6..5ac26ef 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt @@ -1,14 +1,17 @@ package com.codenames.frontend.network.dto +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import kotlinx.serialization.Serializable @Serializable data class GameMessage( - val winner: String? = null, - val currentTurn: String = "", + val winner: Team? = null, + val currentTurn: Team? = null, + val currentPhase: Role? = null, val currentRedFound: Int = 0, val currentBlueFound: Int = 0, - val currentClue: String? = null, + val currentClue: String? = null, //wird noch zu dto geändert val remainingGuesses: Int = 0, val cardList: List = emptyList(), val error: String? = null, 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 5ed63d2..9f5e424 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 @@ -238,8 +238,8 @@ fun GameboardScreen( @Suppress("ktlint:standard:function-naming") @Composable fun GameStatusBar( - currentTurn: String, - winner: String?, + currentTurn: PlayerRoles?, + winner: Team?, remainingGuesses: Int, ) { Row( @@ -252,8 +252,8 @@ fun GameStatusBar( ) { val statusText = when { - !winner.isNullOrBlank() -> "Winner: $winner" - currentTurn.isNotBlank() -> "Turn: $currentTurn | Guesses: $remainingGuesses" + winner != null -> "Winner: $winner" + currentTurn != null -> "Turn: ${currentTurn.name} | Guesses: $remainingGuesses" else -> "Waiting for turn..." } @@ -669,7 +669,7 @@ fun getColor(type: CardType): Color = @Composable fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { var currentHint by rememberSaveable { mutableStateOf("EAGLE") } - var currentTurn by rememberSaveable { mutableStateOf("BLUE") } + var currentTurn by rememberSaveable { mutableStateOf(PlayerRoles.RED_OPERATIVE) } var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } var currentBlueFound by rememberSaveable { mutableIntStateOf(0) } var currentRedFound by rememberSaveable { mutableIntStateOf(0) } @@ -729,8 +729,8 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { when (card.type) { CardType.BLUE -> currentBlueFound++ CardType.RED -> currentRedFound++ - CardType.NEUTRAL -> currentTurn = if (currentTurn == "BLUE") "RED" else "BLUE" - CardType.ASSASSIN -> currentTurn = "GAME OVER" + CardType.NEUTRAL -> currentTurn = if (currentTurn == PlayerRoles.BLUE_SPYMASTER) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_SPYMASTER + CardType.ASSASSIN -> currentTurn = PlayerRoles.NONE } if (remainingGuesses > 0) { diff --git a/app/src/test/java/com/codenames/frontend/UiMappersTest.kt b/app/src/test/java/com/codenames/frontend/UiMappersTest.kt index 1c0e3c8..b5de2f5 100644 --- a/app/src/test/java/com/codenames/frontend/UiMappersTest.kt +++ b/app/src/test/java/com/codenames/frontend/UiMappersTest.kt @@ -1,11 +1,11 @@ package com.codenames.frontend import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.CardDto import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.screens.CardType import com.codenames.frontend.ui.toGameCard import com.codenames.frontend.ui.toPlayerRole import org.junit.Assert.assertEquals @@ -14,10 +14,10 @@ import org.junit.Test class UiMappersTest { @Test fun cardDtoMapsBackendColorsToGameCards() { - assertEquals(CardType.BLUE, CardDto("A", "BLUE", false).toGameCard().type) - assertEquals(CardType.RED, CardDto("A", "RED", false).toGameCard().type) - assertEquals(CardType.ASSASSIN, CardDto("A", "BLACK", false).toGameCard().type) - assertEquals(CardType.NEUTRAL, CardDto("A", "HIDDEN", false).toGameCard().type) + assertEquals(CardType.BLUE, CardDto("A", CardType.BLUE, false).toGameCard().type) + assertEquals(CardType.RED, CardDto("A", CardType.RED, false).toGameCard().type) + assertEquals(CardType.ASSASSIN, CardDto("A", CardType.ASSASSIN, false).toGameCard().type) + assertEquals(CardType.NEUTRAL, CardDto("A", CardType.NEUTRAL, false).toGameCard().type) } @Test 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 8bdbae9..a10b66f 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 @@ -33,6 +33,7 @@ class LobbyRepositoryTest { LobbyResponse( lobbyCode = "1234", playerList = emptyList(), + isStarted = false ) coEvery { api.createLobby(username) } returns response @@ -49,7 +50,7 @@ class LobbyRepositoryTest { val username = "Max" val lobbyCode = "1234" - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.joinLobby(lobbyCode, username) } returns response @@ -65,7 +66,7 @@ class LobbyRepositoryTest { val username = "Max" val lobbyCode = "1234" - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.leaveLobby(username, lobbyCode) } returns response @@ -80,7 +81,7 @@ class LobbyRepositoryTest { runTest { val lobbyCode = "1234" - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.getLobbyInfo(lobbyCode) } returns response @@ -96,7 +97,7 @@ class LobbyRepositoryTest { val role = Role.OPERATIVE val team = Team.RED - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.changeRole(eq(lobbyCode), any()) } returns response 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 c3ac28b..466ca80 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,12 +1,18 @@ package com.codenames.frontend.network.websocket +import android.util.Log import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage +import io.mockk.awaits 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.runs import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest import org.hildan.krossbow.stomp.StompClient @@ -38,7 +44,10 @@ class GameWebSocketHandlerTest { val session = mockk() val sessionWithJson = mockk() + mockkStatic(Log::class) + coEvery { client.connect(BASE_URL) } returns session + every { Log.d(any(), any())} returns 0 coEvery { sessionWithJson.subscribe(any(), any()) 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 b8ada77..c3f7935 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -1,22 +1,29 @@ 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.Player +import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.ChatRepository +import com.codenames.frontend.data.repository.GameRepository import com.codenames.frontend.network.dto.CardDto import com.codenames.frontend.network.dto.GameMessage -import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler +import com.codenames.frontend.ui.roles.PlayerRoles import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -33,32 +40,43 @@ import org.junit.Test class GameViewModelTest { private val testDispatcher = StandardTestDispatcher() - private val lobbyCode = "1234" + 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 = "Hint", + cards = listOf(), + currentTurn = PlayerRoles.RED_OPERATIVE, + winner = null, + remainingGuesses = 0 + ) private val testMessage = GameMessage( - "", - "red", + winner = null, + Team.RED, + Role.SPYMASTER, 0, 0, "", - 0, - emptyList(), + 0 ) + 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(relaxed = true) + client = mockk() chatRepository = mockk(relaxed = true) - viewModel = GameViewModel(client, chatRepository) + gameRepository = mockk(relaxed = true) + viewModel = GameViewModel(client, chatRepository, gameRepository) } @After @@ -81,7 +99,7 @@ class GameViewModelTest { coVerify { client.connectStomp() } coVerify { client.subscribeToLobby(lobbyCode) } - assertEquals(testMessage, viewModel.uiState.value) + assertEquals(testState, viewModel.uiState.value) } @Test @@ -104,22 +122,6 @@ class GameViewModelTest { assertEquals(testMessage, viewModel.uiState.value) } - @Test - fun connect_shouldRegisterWebSocketSession() = - runTest { - val flow = flowOf(testMessage) - - coEvery { client.connectStomp() } just Runs - coEvery { client.subscribeToLobby(lobbyCode) } returns flow - coEvery { client.sendReconnectMessage(any()) } just Runs - - viewModel.connect(username, lobbyCode, team, role) - - advanceUntilIdle() - - coVerify { client.sendReconnectMessage(WebSocketJoinMessage(username, lobbyCode)) } - } - @Test fun testSendLobbyMessage() = runTest { @@ -161,8 +163,13 @@ class GameViewModelTest { 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() + 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() @@ -229,18 +236,21 @@ class GameViewModelTest { @Test fun handleMessage_updatesGameState() = runTest { + mockkStatic(Log::class) + every {Log.d(any(), any())} returns 0 val message = GameMessage( winner = null, - currentTurn = "BLUE", + currentTurn = Team.BLUE, + currentPhase = Role.SPYMASTER, currentRedFound = 1, currentBlueFound = 2, currentClue = "EAGLE", remainingGuesses = 3, cardList = listOf( - CardDto("BERLIN", "BLUE", false), - CardDto("ROME", "RED", true), + CardDto("BERLIN", CardType.BLUE, false), + CardDto("ROME", CardType.RED, true), ), ) @@ -248,10 +258,10 @@ class GameViewModelTest { val state = viewModel.uiState.value - assertEquals("BLUE", state.currentTurn) - assertEquals("EAGLE", state.currentClue) + assertEquals(PlayerRoles.BLUE_SPYMASTER, state.currentTurn) + assertEquals("EAGLE", state.currentHint) assertEquals(3, state.remainingGuesses) - assertEquals(2, state.cardList.size) - assertEquals("BERLIN", state.cardList[0].word) + assertEquals(2, state.cards.size) + assertEquals("BERLIN", state.cards[0].word) } } 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 c132983..eeafb96 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -50,6 +50,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + isStarted = false ) coEvery { repository.createLobby("User") } returns response @@ -100,6 +101,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -107,6 +109,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) val viewModel = LobbyViewModel(repository) @@ -154,12 +157,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) val response2 = LobbyResponse( lobbyCode = "", playerList = emptyList(), + false ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -192,6 +197,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -227,12 +233,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) val response2 = LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", newRole, newTeam, true)), + false ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -268,6 +276,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -350,6 +359,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) val viewModel = LobbyViewModel(repository) @@ -373,8 +383,8 @@ class LobbyViewModelTest { coEvery { repository.getLobbyInfo(any()) } returnsMany listOf( - LobbyResponse("1", emptyList()), - LobbyResponse("2", emptyList()), + LobbyResponse("1", emptyList(), false), + LobbyResponse("2", emptyList(), false), ) val viewModel = LobbyViewModel(repository) @@ -398,6 +408,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) val viewModel = LobbyViewModel(repository) @@ -428,6 +439,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) coEvery { repository.createLobby("User") } returns response @@ -458,6 +470,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false ) coEvery { repository.createLobby("User") } returns response @@ -558,6 +571,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "ABCD", playerList = players, + false ) coEvery { @@ -614,12 +628,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "ABCD", playerList = initialPlayers, + false ) val changeRoleResponse = LobbyResponse( lobbyCode = "ABCD", playerList = updatedPlayers, + false ) coEvery { From 31712c40d4bbb618c838db97436ac2f055557c41 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 22:02:50 +0200 Subject: [PATCH 097/121] test: fix existing tests for game view model --- .../codenames/frontend/data/model/Mapper.kt | 6 +-- .../frontend/network/dto/GameMessage.kt | 2 +- .../frontend/ui/screens/GameboardScreen.kt | 4 +- .../data/repository/LobbyRepositoryTest.kt | 2 +- .../websocket/GameWebSocketHandlerTest.kt | 5 +- .../frontend/viewmodel/GameViewModelTest.kt | 53 ++++++------------- .../frontend/viewmodel/LobbyViewModelTest.kt | 32 +++++------ 7 files changed, 41 insertions(+), 63 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 d870304..b5d4f89 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 @@ -40,11 +40,11 @@ fun CardDto.toGameCard(): GameCard = revealed = isGuessed, ) -fun GameMessage.getCurrentTurn() : PlayerRoles { - if(currentTurn == Team.RED) { +fun GameMessage.getCurrentTurn(): PlayerRoles { + if (currentTurn == Team.RED) { if (currentPhase == Role.SPYMASTER) return PlayerRoles.RED_SPYMASTER return PlayerRoles.RED_OPERATIVE } - if(currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER + if (currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER return PlayerRoles.BLUE_OPERATIVE } 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 5ac26ef..59dae7c 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 @@ -11,7 +11,7 @@ data class GameMessage( val currentPhase: Role? = null, val currentRedFound: Int = 0, val currentBlueFound: Int = 0, - val currentClue: String? = null, //wird noch zu dto geändert + val currentClue: String? = null, // wird noch zu dto geändert val remainingGuesses: Int = 0, val cardList: List = emptyList(), val error: String? = null, 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 9f5e424..121d60d 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 @@ -729,7 +729,9 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { when (card.type) { CardType.BLUE -> currentBlueFound++ CardType.RED -> currentRedFound++ - CardType.NEUTRAL -> currentTurn = if (currentTurn == PlayerRoles.BLUE_SPYMASTER) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_SPYMASTER + CardType.NEUTRAL -> + currentTurn = + if (currentTurn == PlayerRoles.BLUE_SPYMASTER) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_SPYMASTER CardType.ASSASSIN -> currentTurn = PlayerRoles.NONE } 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 a10b66f..a68191d 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 @@ -33,7 +33,7 @@ class LobbyRepositoryTest { LobbyResponse( lobbyCode = "1234", playerList = emptyList(), - isStarted = false + isStarted = false, ) coEvery { api.createLobby(username) } returns response 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 466ca80..e955ceb 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 @@ -5,14 +5,11 @@ import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage -import io.mockk.awaits 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.runs import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest import org.hildan.krossbow.stomp.StompClient @@ -47,7 +44,7 @@ class GameWebSocketHandlerTest { mockkStatic(Log::class) coEvery { client.connect(BASE_URL) } returns session - every { Log.d(any(), any())} returns 0 + every { Log.d(any(), any()) } returns 0 coEvery { sessionWithJson.subscribe(any(), any()) 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 c3f7935..7476046 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -3,7 +3,6 @@ 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.Player import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team @@ -47,11 +46,11 @@ class GameViewModelTest { private val testState = GameState( - currentHint = "Hint", + currentHint = "", cards = listOf(), - currentTurn = PlayerRoles.RED_OPERATIVE, + currentTurn = PlayerRoles.RED_SPYMASTER, winner = null, - remainingGuesses = 0 + remainingGuesses = 0, ) private val testMessage = GameMessage( @@ -61,10 +60,9 @@ class GameViewModelTest { 0, 0, "", - 0 + 0, ) - private lateinit var viewModel: GameViewModel private lateinit var client: GameWebSocketHandler private lateinit var chatRepository: ChatRepository @@ -119,7 +117,7 @@ class GameViewModelTest { coVerify { client.connectStomp() } coVerify { client.subscribeToLobby(lobbyCode) } - assertEquals(testMessage, viewModel.uiState.value) + assertEquals(testState, viewModel.uiState.value) } @Test @@ -182,36 +180,19 @@ class GameViewModelTest { assertEquals("Test msg", currentMessageList[0].text) } - @Test - fun testConnectUpdateTeamChat() = - runTest { - val customLobbyFlow = MutableSharedFlow() - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) } returns customLobbyFlow - - 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.teamMessages - assertEquals("Test msg", currentMessageList[0].text) - } - @Test fun testConnectUpdateOperativeChat() = runTest { - val customLobbyFlow = MutableSharedFlow() - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow + 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 testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) - customLobbyFlow.emit(testChat) - advanceUntilIdle() - val currentMessageList = viewModel.chatState.value.operativeMessages assertEquals("Test msg", currentMessageList[0].text) } @@ -219,16 +200,14 @@ class GameViewModelTest { @Test fun testConnectUpdateOperativeChat_notOperative() = runTest { - val customLobbyFlow = MutableSharedFlow() - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns customLobbyFlow + 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 testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) - customLobbyFlow.emit(testChat) - advanceUntilIdle() - val currentMessageList = viewModel.chatState.value.operativeMessages assertTrue(currentMessageList.isEmpty()) } @@ -237,7 +216,7 @@ class GameViewModelTest { fun handleMessage_updatesGameState() = runTest { mockkStatic(Log::class) - every {Log.d(any(), any())} returns 0 + every { Log.d(any(), any()) } returns 0 val message = GameMessage( winner = null, 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 eeafb96..692a9d6 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -50,7 +50,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - isStarted = false + isStarted = false, ) coEvery { repository.createLobby("User") } returns response @@ -101,7 +101,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -109,7 +109,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) val viewModel = LobbyViewModel(repository) @@ -157,14 +157,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) val response2 = LobbyResponse( lobbyCode = "", playerList = emptyList(), - false + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -197,7 +197,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -233,14 +233,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) val response2 = LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", newRole, newTeam, true)), - false + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -276,7 +276,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -359,7 +359,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) val viewModel = LobbyViewModel(repository) @@ -408,7 +408,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) val viewModel = LobbyViewModel(repository) @@ -439,7 +439,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) coEvery { repository.createLobby("User") } returns response @@ -470,7 +470,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), - false + false, ) coEvery { repository.createLobby("User") } returns response @@ -571,7 +571,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "ABCD", playerList = players, - false + false, ) coEvery { @@ -628,14 +628,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "ABCD", playerList = initialPlayers, - false + false, ) val changeRoleResponse = LobbyResponse( lobbyCode = "ABCD", playerList = updatedPlayers, - false + false, ) coEvery { From fbb5b69112b0ae4c06682ef17051360aa00b7892 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 17 May 2026 22:59:28 +0200 Subject: [PATCH 098/121] test: added more tests --- .../frontend/data/model/MapperTest.kt | 112 ++++++ .../data/repository/GameRepositoryTest.kt | 34 ++ .../data/repository/LobbyRepositoryTest.kt | 24 ++ .../websocket/GameWebSocketHandlerTest.kt | 12 + .../frontend/viewmodel/GameViewModelTest.kt | 150 ++++++++ .../frontend/viewmodel/LobbyViewModelTest.kt | 335 ++++++++++++++++++ 6 files changed, 667 insertions(+) create mode 100644 app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt create mode 100644 app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt diff --git a/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt b/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt new file mode 100644 index 0000000..0003e70 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt @@ -0,0 +1,112 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.network.dto.GameMessage +import com.codenames.frontend.ui.roles.PlayerRoles +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.test.assertNull + +class MapperTest { + @Test + fun getCurrentTurn_redSpymaster_returnsRedSpymaster() { + val message = + GameMessage( + currentTurn = Team.RED, + currentPhase = Role.SPYMASTER, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + } + + @Test + fun getCurrentTurn_redOperative_returnsRedOperative() { + val message = + GameMessage( + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.RED_OPERATIVE, result) + } + + @Test + fun getCurrentTurn_blueSpymaster_returnsBlueSpymaster() { + val message = + GameMessage( + currentTurn = Team.BLUE, + currentPhase = Role.SPYMASTER, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.BLUE_SPYMASTER, result) + } + + @Test + fun getCurrentTurn_blueOperative_returnsBlueOperative() { + val message = + GameMessage( + currentTurn = Team.BLUE, + currentPhase = Role.OPERATIVE, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } + + @Test + fun toGameState_withCurrentClue_mapsCorrectly() { + val gameMessage = + GameMessage( + currentClue = "Animal 3", + cardList = + listOf( + CardDto( + word = "Dog", + color = CardType.RED, + isGuessed = false, + ), + ), + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + winner = null, + remainingGuesses = 2, + ) + + val result = gameMessage.toGameState() + + assertEquals("Animal 3", result.currentHint) + assertEquals(PlayerRoles.RED_OPERATIVE, result.currentTurn) + assertEquals(2, result.remainingGuesses) + assertNull(result.winner) + + assertEquals(1, result.cards.size) + assertEquals("Dog", result.cards[0].word) + } + + @Test + fun toGameState_withNullCurrentClue_usesEmptyString() { + val gameMessage = + GameMessage( + currentClue = null, + cardList = emptyList(), + currentTurn = Team.BLUE, + currentPhase = Role.SPYMASTER, + winner = null, + remainingGuesses = 0, + ) + + val result = gameMessage.toGameState() + + assertEquals("", result.currentHint) + } +} diff --git a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt new file mode 100644 index 0000000..be20743 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -0,0 +1,34 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GameRepositoryTest { + private val webSocketHandler: GameWebSocketHandler = mockk() + private lateinit var gameRepository: GameRepository + + @Before + fun setUp() { + this.gameRepository = GameRepository(webSocketHandler) + } + + @Test + fun testStartGame() = + runTest { + val lobbyCode = "ABCDE" + coEvery { webSocketHandler.startGame(any()) } just Runs + + gameRepository.startGame(lobbyCode) + + coVerify { webSocketHandler.startGame(any()) } + } +} diff --git a/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt index a68191d..0b215ae 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt @@ -4,6 +4,7 @@ import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.api.LobbyApi import com.codenames.frontend.network.dto.LobbyResponse +import com.codenames.frontend.network.dto.PlayerDto import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -126,4 +127,27 @@ class LobbyRepositoryTest { repository.createLobby(username) } } + + @Test + fun sendStartGame_callsApi() = + runTest { + val lobbyCode = "ABCDE" + val username = "Test" + val list = + listOf( + PlayerDto( + username, + null, + null, + true, + ), + ) + + coEvery { api.startGame(any(), any()) } returns LobbyResponse(lobbyCode, list, false) + + val response = repository.sendStartGame(lobbyCode, username) + + coVerify { api.startGame(any(), any()) } + assertEquals(response.lobbyCode, lobbyCode) + } } diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt index e955ceb..e59e8a1 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 @@ -4,6 +4,7 @@ import android.util.Log import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage +import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import io.mockk.coEvery import io.mockk.coVerify @@ -144,4 +145,15 @@ class GameWebSocketHandlerTest { coVerify { session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } } + + @Test + fun testStartGame() = + runTest { + val destination = "/app/start-game" + val msg = StartGameMessage(lobbyCode = "ABCDE") + + wsClient.startGame(msg) + + coVerify { session.convertAndSend(destination, msg, StartGameMessage.serializer()) } + } } 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 7476046..96c01d9 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -243,4 +243,154 @@ class GameViewModelTest { assertEquals(2, state.cards.size) assertEquals("BERLIN", state.cards[0].word) } + + @Test + fun connect_shouldUpdateConnectionStateOnError() = + runTest { + coEvery { + client.connectStomp() + } throws RuntimeException("Connection failed") + + viewModel.connect(username, lobbyCode, team, role) + + advanceUntilIdle() + + val state = viewModel.connectionState.value + + assertTrue(state is com.codenames.frontend.data.model.enums.ConnectionState.Error) + + assertEquals( + "Connection failed", + (state as com.codenames.frontend.data.model.enums.ConnectionState.Error).message, + ) + } + + @Test + fun getCurrentFound_returnsCorrectCount() { + val message = + GameMessage( + winner = null, + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + currentRedFound = 0, + currentBlueFound = 0, + currentClue = "", + remainingGuesses = 0, + 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, + currentRedFound = 0, + currentBlueFound = 0, + currentClue = "", + remainingGuesses = 0, + cardList = + listOf( + CardDto("A", CardType.RED, false), + CardDto("B", CardType.BLUE, false), + ), + ) + + viewModel.handleMessage(message) + + val result = viewModel.getCurrentFound(CardType.RED) + + assertEquals(0, result) + } + + @Test + fun connect_asHost_shouldStartGame() = + runTest { + coEvery { client.connectStomp() } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + + every { + chatRepository.observeChat(any(), any()) + } returns emptyFlow() + + coEvery { + gameRepository.startGame(lobbyCode) + } just Runs + + viewModel.connect( + username, + lobbyCode, + team, + role, + isHost = true, + ) + + advanceUntilIdle() + + coVerify { + gameRepository.startGame(lobbyCode) + } + } + + @Test + fun connect_asHost_blankLobbyCode_shouldNotStartGame() = + runTest { + coEvery { client.connectStomp() } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + + every { + chatRepository.observeChat(any(), any()) + } returns emptyFlow() + + viewModel.connect( + username, + "", + team, + role, + isHost = true, + ) + + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.startGame(any()) + } + } + + @Test + fun handleMessage_withNullClue_setsEmptyHint() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + val message = + GameMessage( + winner = null, + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + currentRedFound = 0, + currentBlueFound = 0, + currentClue = null, + remainingGuesses = 1, + cardList = emptyList(), + ) + + viewModel.handleMessage(message) + + assertEquals("", viewModel.uiState.value.currentHint) + } } 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 692a9d6..d5d8fce 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.viewmodel +import android.util.Log import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.LobbyRepository @@ -8,7 +9,9 @@ import com.codenames.frontend.network.dto.PlayerDto import com.codenames.frontend.ui.roles.PlayerRoles import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.Dispatchers @@ -695,4 +698,336 @@ class LobbyViewModelTest { ?.contains("Not in a Lobby") == true, ) } + + @Test + fun changeRole_DelegatesCorrectly_redSpymaster() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository)) + + viewModel.changeRole(PlayerRoles.RED_SPYMASTER, "Alice") + + verify { + viewModel.changeRole(Role.SPYMASTER, Team.RED, "Alice") + } + } + + @Test + fun changeRole_DelegatesCorrectly_blueOperative() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository)) + + viewModel.changeRole(PlayerRoles.BLUE_OPERATIVE, "Bob") + + verify { + viewModel.changeRole(Role.OPERATIVE, Team.BLUE, "Bob") + } + } + + @Test + fun changeRole_invalidRole_setsError() = + runTest { + val repository = mockk() + + val viewModel = LobbyViewModel(repository) + + viewModel.changeRole(PlayerRoles.NONE, "User") + + advanceUntilIdle() + + assertEquals( + "Invalid role", + viewModel.state.value.error, + ) + } + + @Test + fun getRoleForUser_userNotFound_returnsNone() { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val result = viewModel.getRoleForUser("Unknown") + + assertEquals(PlayerRoles.NONE, result) + } + + @Test + fun sendStartGame_blankUsername_doesNothing() = + runTest { + val repository = mockk(relaxed = true) + + val viewModel = LobbyViewModel(repository) + + viewModel.sendStartGame("") + + advanceUntilIdle() + + coVerify(exactly = 0) { + repository.sendStartGame(any(), any()) + } + } + + @Test + fun getRoleForUser_blueOperative_returnsCorrectRole() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Alice", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = false, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Alice", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Alice") + + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } + + @Test + fun getRoleForUser_redSpymaster_returnsCorrectRole() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Bob", + role = Role.SPYMASTER, + team = Team.RED, + isHost = false, + ), + ), + false, + ) + + coEvery { + repository.joinLobby("Bob", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Bob", "12345") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Bob") + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + } + + @Test + fun getRoleForUser_nullRole_returnsNone() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Bob", + role = null, + team = Team.RED, + isHost = false, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Bob", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Bob", "12345") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Bob") + + assertEquals(PlayerRoles.NONE, result) + } + + @Test + fun getIsHost_returnsTrueForHost() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Host", + role = null, + team = null, + isHost = true, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Host", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Host", "12345") + + advanceUntilIdle() + + assertTrue(viewModel.getIsHost("Host")) + } + + @Test + fun getIsHost_returnsFalseForNonHost() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "User", + isHost = false, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("User", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("User", "12345") + + advanceUntilIdle() + + assertFalse(viewModel.getIsHost("User")) + } + + @Test + fun sendStartGame_callsRepository() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + val repository = mockk() + + val joinResponse = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Host", + isHost = true, + ), + ), + isStarted = false, + ) + + val startResponse = + LobbyResponse( + lobbyCode = "12345", + playerList = joinResponse.playerList, + isStarted = true, + ) + + coEvery { + repository.joinLobby("Host", "12345") + } returns joinResponse + + coEvery { + repository.sendStartGame("12345", "Host") + } returns startResponse + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Host", "12345") + + advanceUntilIdle() + + viewModel.sendStartGame("Host") + + advanceUntilIdle() + + coVerify { + repository.sendStartGame("12345", "Host") + } + } + + @Test + fun sendStartGame_exception_setsError() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + val repository = mockk() + + val joinResponse = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Host", + isHost = true, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Host", "12345") + } returns joinResponse + + coEvery { + repository.sendStartGame(any(), any()) + } throws RuntimeException("Start failed") + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Host", "12345") + + advanceUntilIdle() + + viewModel.sendStartGame("Host") + + advanceUntilIdle() + + assertEquals( + "Start failed", + viewModel.state.value.error, + ) + } } From 4e8706376cbdf806e918389fc3b45aaa6972d871 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 12:33:32 +0200 Subject: [PATCH 099/121] fix: extract team out of Turn since we are now usign game state, and game state only holds Team_Role, the temporary fix is to regex the first part. This is extremely brittle and prone to errors but has to work for sprint 2 --- .../java/com/codenames/frontend/viewmodel/GameViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 3c63454..86d4211 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -8,6 +8,7 @@ import com.codenames.frontend.data.model.GameState import com.codenames.frontend.data.model.enums.CardType 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 @@ -156,9 +157,12 @@ class GameViewModel count: Int, ) { val turn = uiState.value.currentTurn ?: return + val turnString = turn.toString() + val teamString = turnString.split("_").first() + val team = Team.valueOf(teamString) viewModelScope.launch { try { - client.sendClue(lobbyCode, word, count, turn) + client.sendClue(lobbyCode, word, count, team) } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } From ce9bc0e4d2ef01e93dca882ec4ab296e8f30da78 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 12:48:15 +0200 Subject: [PATCH 100/121] fix: extract word from DTO to currentHint --- app/src/main/java/com/codenames/frontend/data/model/Mapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b5d4f89..778d6f7 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 @@ -26,7 +26,7 @@ fun PlayerDto.toUi(): Player = fun GameMessage.toGameState(): GameState = GameState( - currentHint = currentClue ?: "", + currentHint = currentClue?.word ?: "", cards = cardList.map { it.toGameCard() }, currentTurn = getCurrentTurn(), winner = winner, From dd88612846b39ef5f8b5edd30bc6443303faadaf Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 13:17:21 +0200 Subject: [PATCH 101/121] fix: add back mapping to mock screen, else it wont build --- .../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 e86ef3f..d280756 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 @@ -767,7 +767,8 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { remainingGuesses = remainingGuesses, cards = cards, ), - onHintChange = { /* not necessary for offline test screen */ }, + onHintChange = {word, count -> currentHint = word + remainingGuesses = count}, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, gameViewModel = gameViewModel, From 16e46d88d9471cdc4aa4b70ffa827b08f7f812fd Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 13:17:50 +0200 Subject: [PATCH 102/121] fix: read gameState card instead of old cardList from GameMessage --- .../codenames/frontend/ui/screens/GameScreenWrapper.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 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 8ac38c0..3f6488c 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,19 +26,14 @@ fun GameScreenWrapper( val currentPlayer = lobbyState.players.firstOrNull { it.name == usernameState.username } val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() - val cards = gameState.cardList.map { it.toGameCard() } - val currentHintText = - gameState.currentClue?.let { - "${it.word} ${it.guessAmount}" - } ?: "Waiting for hint..." + val cards = gameState.cards GameboardScreen( userRole = userRole, gameState = GameState( - currentHint = currentHintText, + currentHint = gameState.currentHint, currentTurn = gameState.currentTurn, - currentPhase = gameState.currentPhase, winner = gameState.winner, remainingGuesses = gameState.remainingGuesses, cards = cards, From 49ca7e2aa93b8bce26ffc44691685e313be97818 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 13:18:20 +0200 Subject: [PATCH 103/121] fix: remove null check since not needed --- .../main/java/com/codenames/frontend/viewmodel/GameViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 86d4211..bbbbaa1 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -156,7 +156,7 @@ class GameViewModel word: String, count: Int, ) { - val turn = uiState.value.currentTurn ?: return + val turn = uiState.value.currentTurn val turnString = turn.toString() val teamString = turnString.split("_").first() val team = Team.valueOf(teamString) From c9dd64bf4a48ea9996ec507ec5195dbed87876f1 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 13:18:45 +0200 Subject: [PATCH 104/121] fix: change string to clueDTO as it is expected for clue --- .../java/com/codenames/frontend/data/model/MapperTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 0003e70..834de15 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 @@ -4,6 +4,7 @@ import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.network.dto.ClueDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.ui.roles.PlayerRoles import org.junit.Assert.assertEquals @@ -67,7 +68,7 @@ class MapperTest { fun toGameState_withCurrentClue_mapsCorrectly() { val gameMessage = GameMessage( - currentClue = "Animal 3", + currentClue = ClueDto(word = "Animal", guessAmount = 3), cardList = listOf( CardDto( @@ -84,7 +85,7 @@ class MapperTest { val result = gameMessage.toGameState() - assertEquals("Animal 3", result.currentHint) + assertEquals("Animal", result.currentHint) assertEquals(PlayerRoles.RED_OPERATIVE, result.currentTurn) assertEquals(2, result.remainingGuesses) assertNull(result.winner) From b3737ad4581b5e582aaf86557d57b514678bde42 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 13:19:36 +0200 Subject: [PATCH 105/121] fix: change string to clueDTO and change assertion values --- .../codenames/frontend/viewmodel/GameViewModelTest.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 23d017f..059f420 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -222,7 +222,7 @@ class GameViewModelTest { GameMessage( winner = null, currentTurn = Team.BLUE, - currentPhase = Role.SPYMASTER, + currentPhase = Role.OPERATIVE, currentRedFound = 1, currentBlueFound = 2, currentClue = ClueDto("EAGLE", 3), @@ -238,9 +238,8 @@ class GameViewModelTest { val state = viewModel.uiState.value - assertEquals(Team.BLUE, state.currentTurn) - assertEquals(Role.OPERATIVE, state.currentPhase) - assertEquals("EAGLE", state.currentClue?.word) + assertEquals(PlayerRoles.BLUE_OPERATIVE, state.currentTurn) + assertEquals("EAGLE", state.currentHint) assertEquals(3, state.remainingGuesses) assertEquals(2, state.cards.size) assertEquals("BERLIN", state.cards[0].word) @@ -276,7 +275,7 @@ class GameViewModelTest { currentPhase = Role.OPERATIVE, currentRedFound = 0, currentBlueFound = 0, - currentClue = "", + currentClue = ClueDto(word = "Animal", guessAmount = 3), remainingGuesses = 0, cardList = listOf( @@ -303,7 +302,7 @@ class GameViewModelTest { currentPhase = Role.OPERATIVE, currentRedFound = 0, currentBlueFound = 0, - currentClue = "", + currentClue = ClueDto(word = "Animal", guessAmount = 3), remainingGuesses = 0, cardList = listOf( From a5a67ead4b4af667b0007f9026596d2781e7f79f Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 13:28:36 +0200 Subject: [PATCH 106/121] refactor: linting --- .../com/codenames/frontend/ui/screens/GameboardScreen.kt | 7 ++++--- 1 file changed, 4 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 d280756..29a2b41 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 @@ -51,7 +51,6 @@ import com.codenames.frontend.data.model.GameCard import com.codenames.frontend.data.model.GameState import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.ChatTab -import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle @@ -767,8 +766,10 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { remainingGuesses = remainingGuesses, cards = cards, ), - onHintChange = {word, count -> currentHint = word - remainingGuesses = count}, + onHintChange = { word, count -> + currentHint = word + remainingGuesses = count + }, onReveal = { index -> revealCard(index) }, onSendChatMessage = {}, gameViewModel = gameViewModel, From a3e34b3ceec56c08749835357fa39b6db3e20819 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 16:37:24 +0200 Subject: [PATCH 107/121] refactor: change regex team extraction to proper if else check --- .../java/com/codenames/frontend/viewmodel/GameViewModel.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 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 bbbbaa1..eed3d66 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.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -157,9 +158,9 @@ class GameViewModel count: Int, ) { val turn = uiState.value.currentTurn - val turnString = turn.toString() - val teamString = turnString.split("_").first() - val team = Team.valueOf(teamString) + if (turn != PlayerRoles.BLUE_SPYMASTER && turn != PlayerRoles.RED_SPYMASTER) return + + val team = if (turn == PlayerRoles.BLUE_SPYMASTER) Team.BLUE else Team.RED viewModelScope.launch { try { client.sendClue(lobbyCode, word, count, team) From 60543807099157ebdb6a67334e99c57b45c38875 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 17:21:09 +0200 Subject: [PATCH 108/121] test: add test for both branches for sending clue as spymaster and not --- .../frontend/viewmodel/GameViewModelTest.kt | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) 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 059f420..a8868b7 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -396,17 +396,45 @@ class GameViewModelTest { } @Test - fun testSubmitClue() = - runTest { - val word = "EAGLE" - val count = 2 - viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED)) + fun testSubmitClue_RedSpymaster_Success() = runTest { + coEvery { + client.sendClue(any(), any(), any(), any()) + } just Runs - viewModel.submitClue(lobbyCode, word, count) - advanceUntilIdle() + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + + coVerify { client.sendClue(lobbyCode, "EAGLE", 2, Team.RED) } + } + + @Test + fun testSubmitClue_NetworkError_UpdatesConnectionState() = runTest { + coEvery { + client.sendClue(any(), any(), any(), any()) + } throws Exception("Network connection failed") + + viewModel.handleMessage(testMessage.copy( + currentTurn = Team.RED, + currentPhase = Role.SPYMASTER + )) + + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + } + + @Test + fun testSubmitClue_whenTurnIsNone_doesNotSendClue() = runTest { + // never call handleMessage so turn is NONE + + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + + coVerify(exactly = 0) { client.sendClue(any(), any(), any(), any()) } + } - coVerify { - client.sendClue(lobbyCode, word, count, Team.RED) - } - } } From 251c7e93d7240e92d28c1fc63d97b060033f8536 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 17:53:52 +0200 Subject: [PATCH 109/121] test: add test for Team Chat similar to existing tests (need assist) --- .../frontend/viewmodel/GameViewModelTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 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 a8868b7..8b492f5 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -4,6 +4,7 @@ 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.ConnectionState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.ChatRepository @@ -181,6 +182,25 @@ class GameViewModelTest { assertEquals("Test msg", currentMessageList[0].text) } + @Test + fun testConnectUpdateTeamChat() = + 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 flowOf(testChat) + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns emptyFlow() + + viewModel.connect(username, lobbyCode, team, role) + advanceUntilIdle() + + val currentMessageList = viewModel.chatState.value.teamMessages + assertEquals("Test msg", currentMessageList[0].text) + } + @Test fun testConnectUpdateOperativeChat() = runTest { From 55d101c77238e723751bc71c06f06eb47c7f3de0 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 17:55:31 +0200 Subject: [PATCH 110/121] test: remove test, it's an old problem where we have not found solution --- .../frontend/viewmodel/GameViewModelTest.kt | 19 ------------------- 1 file changed, 19 deletions(-) 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 8b492f5..456607e 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -182,25 +182,6 @@ class GameViewModelTest { assertEquals("Test msg", currentMessageList[0].text) } - @Test - fun testConnectUpdateTeamChat() = - 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 flowOf(testChat) - every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns emptyFlow() - - viewModel.connect(username, lobbyCode, team, role) - advanceUntilIdle() - - val currentMessageList = viewModel.chatState.value.teamMessages - assertEquals("Test msg", currentMessageList[0].text) - } - @Test fun testConnectUpdateOperativeChat() = runTest { From ffdeca6dc4d886a918b29920cf04b8382ab65295 Mon Sep 17 00:00:00 2001 From: XtophB Date: Mon, 18 May 2026 18:02:41 +0200 Subject: [PATCH 111/121] refactor: kitlintFormat --- .../frontend/viewmodel/GameViewModelTest.kt | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) 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 456607e..6361f1b 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -397,45 +397,49 @@ class GameViewModelTest { } @Test - fun testSubmitClue_RedSpymaster_Success() = runTest { - coEvery { - client.sendClue(any(), any(), any(), any()) - } just Runs + fun testSubmitClue_RedSpymaster_Success() = + runTest { + coEvery { + client.sendClue(any(), any(), any(), any()) + } just Runs - viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) - viewModel.submitClue(lobbyCode, "EAGLE", 2) - advanceUntilIdle() + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() - coVerify { client.sendClue(lobbyCode, "EAGLE", 2, Team.RED) } - } + coVerify { client.sendClue(lobbyCode, "EAGLE", 2, Team.RED) } + } @Test - fun testSubmitClue_NetworkError_UpdatesConnectionState() = runTest { - coEvery { - client.sendClue(any(), any(), any(), any()) - } throws Exception("Network connection failed") + fun testSubmitClue_NetworkError_UpdatesConnectionState() = + runTest { + coEvery { + client.sendClue(any(), any(), any(), any()) + } throws Exception("Network connection failed") - viewModel.handleMessage(testMessage.copy( - currentTurn = Team.RED, - currentPhase = Role.SPYMASTER - )) + viewModel.handleMessage( + testMessage.copy( + currentTurn = Team.RED, + currentPhase = Role.SPYMASTER, + ), + ) - viewModel.submitClue(lobbyCode, "EAGLE", 2) - advanceUntilIdle() + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() - val state = viewModel.connectionState.value - assertTrue(state is ConnectionState.Error) - } + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + } @Test - fun testSubmitClue_whenTurnIsNone_doesNotSendClue() = runTest { - // never call handleMessage so turn is NONE - - viewModel.submitClue(lobbyCode, "EAGLE", 2) - advanceUntilIdle() + fun testSubmitClue_whenTurnIsNone_doesNotSendClue() = + runTest { + // never call handleMessage so turn is NONE - coVerify(exactly = 0) { client.sendClue(any(), any(), any(), any()) } - } + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + coVerify(exactly = 0) { client.sendClue(any(), any(), any(), any()) } + } } From 4a0b3b603dc00b58cdd7fc7d615709e3566c1461 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Mon, 18 May 2026 21:27:23 +0200 Subject: [PATCH 112/121] fixed mapping of game state dto to correctly transfer clue contents --- app/src/main/java/com/codenames/frontend/data/model/Mapper.kt | 2 +- .../java/com/codenames/frontend/network/dto/GameMessage.kt | 3 --- 2 files changed, 1 insertion(+), 4 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 778d6f7..08d1922 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 @@ -30,7 +30,7 @@ fun GameMessage.toGameState(): GameState = cards = cardList.map { it.toGameCard() }, currentTurn = getCurrentTurn(), winner = winner, - remainingGuesses = remainingGuesses, + remainingGuesses = currentClue?.guessAmount ?: 0, ) fun CardDto.toGameCard(): GameCard = 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 f508c77..bd832c9 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt @@ -9,10 +9,7 @@ data class GameMessage( val winner: Team? = null, val currentTurn: Team? = null, val currentPhase: Role? = null, - val currentRedFound: Int = 0, - val currentBlueFound: Int = 0, val currentClue: ClueDto? = null, - val remainingGuesses: Int = 0, val cardList: List = emptyList(), val error: String? = null, ) From 37feae508f79114beb2cf7a32ac9ff2ab2e7311d Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Mon, 18 May 2026 21:36:35 +0200 Subject: [PATCH 113/121] test: fix tests --- .../frontend/data/model/MapperTest.kt | 4 +--- .../frontend/viewmodel/GameViewModelTest.kt | 20 +++---------------- 2 files changed, 4 insertions(+), 20 deletions(-) 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 834de15..c1ddcb4 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 @@ -68,7 +68,7 @@ class MapperTest { fun toGameState_withCurrentClue_mapsCorrectly() { val gameMessage = GameMessage( - currentClue = ClueDto(word = "Animal", guessAmount = 3), + currentClue = ClueDto(word = "Animal", guessAmount = 2), cardList = listOf( CardDto( @@ -80,7 +80,6 @@ class MapperTest { currentTurn = Team.RED, currentPhase = Role.OPERATIVE, winner = null, - remainingGuesses = 2, ) val result = gameMessage.toGameState() @@ -103,7 +102,6 @@ class MapperTest { currentTurn = Team.BLUE, currentPhase = Role.SPYMASTER, winner = null, - remainingGuesses = 0, ) val result = gameMessage.toGameState() 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 6361f1b..9afd646 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -59,10 +59,8 @@ class GameViewModelTest { winner = null, Team.RED, Role.SPYMASTER, - 0, - 0, null, - 0, + listOf(), ) private lateinit var viewModel: GameViewModel @@ -224,10 +222,7 @@ class GameViewModelTest { winner = null, currentTurn = Team.BLUE, currentPhase = Role.OPERATIVE, - currentRedFound = 1, - currentBlueFound = 2, currentClue = ClueDto("EAGLE", 3), - remainingGuesses = 3, cardList = listOf( CardDto("BERLIN", CardType.BLUE, false), @@ -259,11 +254,11 @@ class GameViewModelTest { val state = viewModel.connectionState.value - assertTrue(state is com.codenames.frontend.data.model.enums.ConnectionState.Error) + assertTrue(state is ConnectionState.Error) assertEquals( "Connection failed", - (state as com.codenames.frontend.data.model.enums.ConnectionState.Error).message, + (state as ConnectionState.Error).message, ) } @@ -274,10 +269,7 @@ class GameViewModelTest { winner = null, currentTurn = Team.RED, currentPhase = Role.OPERATIVE, - currentRedFound = 0, - currentBlueFound = 0, currentClue = ClueDto(word = "Animal", guessAmount = 3), - remainingGuesses = 0, cardList = listOf( CardDto("A", CardType.RED, true), @@ -301,10 +293,7 @@ class GameViewModelTest { winner = null, currentTurn = Team.RED, currentPhase = Role.OPERATIVE, - currentRedFound = 0, - currentBlueFound = 0, currentClue = ClueDto(word = "Animal", guessAmount = 3), - remainingGuesses = 0, cardList = listOf( CardDto("A", CardType.RED, false), @@ -384,10 +373,7 @@ class GameViewModelTest { winner = null, currentTurn = Team.RED, currentPhase = Role.OPERATIVE, - currentRedFound = 0, - currentBlueFound = 0, currentClue = null, - remainingGuesses = 1, cardList = emptyList(), ) From fbdc6db971a79dee1e2da307d89adc7f2afda16d Mon Sep 17 00:00:00 2001 From: ad-devel Date: Mon, 18 May 2026 22:38:07 +0200 Subject: [PATCH 114/121] chat_ui_state_update_fix --- .../codenames/frontend/GameboardScreenTest.kt | 39 +++--- .../frontend/data/model/GameState.kt | 1 + .../frontend/ui/navigation/NavGraph.kt | 4 +- .../frontend/ui/screens/GameScreenWrapper.kt | 58 +++++---- .../frontend/ui/screens/GameboardScreen.kt | 111 ++++++++++-------- .../frontend/viewmodel/GameViewModel.kt | 7 +- .../frontend/viewmodel/GameViewModelTest.kt | 4 + 7 files changed, 131 insertions(+), 93 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index c32258f..69f3919 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -7,10 +7,11 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.data.model.ChatLists +import com.codenames.frontend.data.model.GameCard +import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.screens.CardType -import com.codenames.frontend.ui.screens.GameCard -import com.codenames.frontend.ui.screens.GameState import com.codenames.frontend.ui.screens.GameboardScreen import org.junit.Rule import org.junit.Test @@ -35,21 +36,19 @@ class GameboardScreenTest { gameState = GameState( currentHint = "EAGLE", - currentTurn = "BLUE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, remainingGuesses = 3, - currentBlueFound = 2, - currentRedFound = 1, cards = cards, ), - onHintChange = {}, + onHintChange = { _, _ -> }, onReveal = {}, ) } composeRule.onNodeWithText("BERLIN").assertIsDisplayed() composeRule.onNodeWithText("ROME").assertIsDisplayed() - composeRule.onNodeWithText("Turn: BLUE | Guesses: 3").assertIsDisplayed() - composeRule.onNodeWithText("2 FOUND").assertIsDisplayed() + composeRule.onNodeWithText("Turn: BLUE_OPERATIVE | Guesses: 3").assertIsDisplayed() + composeRule.onNodeWithText("0 FOUND").assertIsDisplayed() composeRule.onNodeWithText("1 FOUND").assertIsDisplayed() composeRule.onAllNodesWithText("Hint: EAGLE").assertCountEquals(0) } @@ -64,7 +63,7 @@ class GameboardScreenTest { currentHint = "EAGLE", cards = listOf(GameCard("BERLIN", CardType.BLUE)), ), - onHintChange = {}, + onHintChange = { _, _ -> }, onReveal = {}, ) } @@ -81,21 +80,25 @@ class GameboardScreenTest { GameState( currentHint = "EAGLE", cards = listOf(GameCard("BERLIN", CardType.BLUE)), - chatMessages = - listOf( - ChatDomainModel( - sender = "Max", - text = "Take Berlin", - isFromMe = false, - ), + chatLists = + ChatLists( + teamMessages = + listOf( + ChatDomainModel( + sender = "Max", + text = "Take Berlin", + isFromMe = false, + ), + ), ), ), - onHintChange = {}, + onHintChange = { _, _ -> }, onReveal = {}, ) } composeRule.onNodeWithText("Chat").performClick() + composeRule.onNodeWithText("Team").performClick() composeRule.onNodeWithText("Max").assertIsDisplayed() composeRule.onNodeWithText("Take Berlin").assertIsDisplayed() } 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 246820a..d57e972 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 @@ -9,4 +9,5 @@ data class GameState( val currentTurn: PlayerRoles = PlayerRoles.NONE, val winner: Team? = null, val remainingGuesses: Int = 0, + val chatLists: ChatLists = ChatLists(), ) 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 f9d74b3..4db8e72 100644 --- a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt @@ -77,9 +77,7 @@ fun NavGraph( } composable("game_test") { - OfflineGameStateTestScreen( - gameViewModel, - ) + OfflineGameStateTestScreen() } } } 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 3f6488c..8cd172c 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 @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel @@ -20,26 +20,18 @@ fun GameScreenWrapper( ) { val lobbyState by lobbyViewModel.state.collectAsState() val gameState by gameViewModel.uiState.collectAsState() + val chatState by gameViewModel.chatState.collectAsState() val usernameState by sessionViewModel.username.collectAsState() val userRole = lobbyViewModel.getRoleForUser(usernameState.username) val currentPlayer = lobbyState.players.firstOrNull { it.name == usernameState.username } val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() - val cards = gameState.cards GameboardScreen( userRole = userRole, - gameState = - GameState( - currentHint = gameState.currentHint, - currentTurn = gameState.currentTurn, - winner = gameState.winner, - remainingGuesses = gameState.remainingGuesses, - cards = cards, - ), + gameState = gameState.copy(chatLists = chatState), onHintChange = { word, count -> - if (lobbyCode.isNotBlank()) { gameViewModel.submitClue(lobbyCode, word, count) } @@ -47,19 +39,45 @@ fun GameScreenWrapper( onReveal = { // TODO: Send guess through GameViewModel once backend endpoint exists. }, - onSendChatMessage = { message -> - if (lobbyCode.isNotBlank() && team != null) { - gameViewModel.sendTeamMessage( - lobbyCode = lobbyCode, - team = team.name, - username = usernameState.username, - content = message, - ) + onSendChatMessage = { tab, message -> + if (lobbyCode.isBlank()) { + return@GameboardScreen + } + + when (tab) { + ChatTab.GLOBAL -> { + gameViewModel.sendLobbyMessage( + lobbyCode = lobbyCode, + username = usernameState.username, + content = message, + ) + } + + ChatTab.TEAM -> { + if (team != null) { + gameViewModel.sendTeamMessage( + lobbyCode = lobbyCode, + team = team.name, + username = usernameState.username, + content = message, + ) + } + } + + ChatTab.OPERATIVES -> { + if (team != null) { + gameViewModel.sendOperativeMessage( + lobbyCode = lobbyCode, + team = team.name, + username = usernameState.username, + content = message, + ) + } + } } }, onSettingsClick = { navController.navigate(Screen.Settings.route) }, - gameViewModel = gameViewModel, ) } 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 29a2b41..842162a 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 @@ -21,7 +21,6 @@ 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -44,9 +43,8 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import com.codenames.frontend.data.model.ChatDomainModel -import com.codenames.frontend.data.model.ChatMessage +import com.codenames.frontend.data.model.ChatLists import com.codenames.frontend.data.model.GameCard import com.codenames.frontend.data.model.GameState import com.codenames.frontend.data.model.enums.CardType @@ -65,8 +63,6 @@ import com.codenames.frontend.ui.roles.PlayerRoles 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.viewmodel.ChatViewModel -import com.codenames.frontend.viewmodel.GameViewModel @Suppress("ktlint:standard:function-naming") @Composable @@ -76,22 +72,21 @@ fun GameboardScreen( onHintChange: (String, Int) -> Unit, onReveal: (Int) -> Unit, modifier: Modifier = Modifier, - onSendChatMessage: (String) -> Unit = {}, + onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, - chatViewModel: ChatViewModel = viewModel(), - gameViewModel: GameViewModel, ) { val currentHint = gameState.currentHint val cards = gameState.cards val currentTurn = gameState.currentTurn val winner = gameState.winner val remainingGuesses = gameState.remainingGuesses - val currentRedFound = gameViewModel.getCurrentFound(CardType.RED) - val currentBlueFound = gameViewModel.getCurrentFound(CardType.BLUE) - val chatUiState by chatViewModel.uiState.collectAsState() + val chatLists = gameState.chatLists + val currentRedFound = cards.count { it.type == CardType.RED && it.revealed } + val currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed } var hintInput by rememberSaveable { mutableStateOf("") } var countInput by rememberSaveable { mutableStateOf("") } + var chatInput by rememberSaveable { mutableStateOf("") } var isChatOpen by rememberSaveable { mutableStateOf(false) } var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } @@ -199,16 +194,17 @@ fun GameboardScreen( if (!isSpymaster && isChatOpen) { ChatWindow( - chatInput = chatUiState.currentInput, - messages = chatUiState.messages, + chatInput = chatInput, + messages = chatLists, selectedTab = selectedChatTab, onTabSelected = { selectedChatTab = it }, - onChatInputChange = { chatViewModel.updateInput(it) }, + onChatInputChange = { chatInput = it }, onSendClick = { tab -> - chatViewModel.sendMessage( - username = "Player1", - tab = tab, - ) + val trimmedMessage = chatInput.trim() + if (trimmedMessage.isNotBlank()) { + onSendChatMessage(tab, trimmedMessage) + chatInput = "" + } }, modifier = Modifier @@ -297,7 +293,7 @@ fun ChatToggleButton( @Composable fun ChatWindow( chatInput: String, - messages: List, + messages: ChatLists, selectedTab: ChatTab, onTabSelected: (ChatTab) -> Unit, onChatInputChange: (String) -> Unit, @@ -398,10 +394,17 @@ fun ChatWindow( @Suppress("ktlint:standard:function-naming") @Composable fun ChatMessagesArea( - messages: List, - modifier: Modifier = Modifier, + messages: ChatLists, selectedTab: ChatTab, + modifier: Modifier = Modifier, ) { + val visibleMessages = + when (selectedTab) { + ChatTab.GLOBAL -> messages.lobbyMessages + ChatTab.TEAM -> messages.teamMessages + ChatTab.OPERATIVES -> messages.operativeMessages + } + Column( modifier = modifier @@ -420,7 +423,7 @@ fun ChatMessagesArea( Spacer(modifier = Modifier.height(8.dp)) - if (messages.isEmpty()) { + if (visibleMessages.isEmpty()) { Text( text = "No messages yet.", color = Color(0xFF383330), @@ -430,11 +433,7 @@ fun ChatMessagesArea( LazyColumn( verticalArrangement = Arrangement.spacedBy(6.dp), ) { - items( - messages.filter { - it.chatTab == selectedTab - }, - ) { message -> + items(visibleMessages) { message -> ChatMessageBubble(message = message) } } @@ -444,7 +443,7 @@ fun ChatMessagesArea( @Suppress("ktlint:standard:function-naming") @Composable -fun ChatMessageBubble(message: ChatMessage) { +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) @@ -468,7 +467,7 @@ fun ChatMessageBubble(message: ChatMessage) { .padding(8.dp), ) { Text( - text = message.message, + text = message.text, color = textColor, fontSize = 13.sp, ) @@ -684,12 +683,10 @@ fun getColor(type: CardType): Color = @Suppress("ktlint:standard:function-naming") @Composable -fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { +fun OfflineGameStateTestScreen() { var currentHint by rememberSaveable { mutableStateOf("EAGLE") } var currentTurn by rememberSaveable { mutableStateOf(PlayerRoles.RED_OPERATIVE) } var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } - var currentBlueFound by rememberSaveable { mutableIntStateOf(0) } - var currentRedFound by rememberSaveable { mutableIntStateOf(0) } val cards = remember { @@ -722,18 +719,37 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { ) } - val chatMessages = - listOf( - ChatDomainModel( - sender = "Max", - text = "I think BERLIN fits the hint.", - isFromMe = false, - ), - ChatDomainModel( - sender = "You", - text = "Maybe RIVER too.", - isFromMe = true, - ), + 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) { @@ -744,12 +760,11 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { cards[index] = card.copy(revealed = true) when (card.type) { - CardType.BLUE -> currentBlueFound++ - CardType.RED -> currentRedFound++ 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) { @@ -765,13 +780,13 @@ fun OfflineGameStateTestScreen(gameViewModel: GameViewModel) { currentTurn = currentTurn, remainingGuesses = remainingGuesses, cards = cards, + chatLists = chatLists, ), onHintChange = { word, count -> currentHint = word remainingGuesses = count }, onReveal = { index -> revealCard(index) }, - onSendChatMessage = {}, - gameViewModel = gameViewModel, + onSendChatMessage = { _, _ -> }, ) } 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 eed3d66..fad80bd 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -13,6 +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.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel @@ -37,10 +38,7 @@ class GameViewModel private val _uiState = MutableStateFlow(GameState()) val uiState: StateFlow = _uiState - // _chatState is mutable and should only be used by view model private val _chatState = MutableStateFlow(ChatLists()) - - // chatState is not mutable and is meant for the UI val chatState: StateFlow = _chatState private val _connectionState = MutableStateFlow(ConnectionState.IDLE) @@ -54,6 +52,7 @@ class GameViewModel isHost: Boolean = false, ) { job?.cancel() + _chatState.value = ChatLists() job = viewModelScope.launch { @@ -61,6 +60,7 @@ class GameViewModel try { client.connectStomp() + client.sendReconnectMessage(WebSocketJoinMessage(username, lobbyCode)) Log.d("GameViewModel", "Connection successful") @@ -75,7 +75,6 @@ class GameViewModel Log.d("GameViewModel", "Subscribed to Lobby") launch { - // msg is the domain model chat we emit in the ChatRepository chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> _chatState.update { currentState -> currentState.copy(lobbyMessages = currentState.lobbyMessages + 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 9afd646..fc47088 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -74,6 +74,10 @@ class GameViewModelTest { client = mockk() chatRepository = mockk(relaxed = true) gameRepository = mockk(relaxed = true) + + coEvery { client.sendReconnectMessage(any()) } just Runs + every { chatRepository.observeChat(any(), any()) } returns emptyFlow() + viewModel = GameViewModel(client, chatRepository, gameRepository) } From 3f11a532d5ac1ac84985163d47cc2c92ff40c644 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Tue, 19 May 2026 19:57:09 +0200 Subject: [PATCH 115/121] Chat_ui_state_fix - fixed issues after review --- .../codenames/frontend/GameboardScreenTest.kt | 57 +++++++++++++++++++ .../frontend/data/model/GameState.kt | 4 ++ .../codenames/frontend/data/model/Mapper.kt | 14 +++-- .../frontend/ui/screens/GameScreenWrapper.kt | 29 +++++++++- .../frontend/ui/screens/GameboardScreen.kt | 43 +++++++++----- .../frontend/viewmodel/GameViewModel.kt | 3 - .../frontend/viewmodel/GameViewModelTest.kt | 1 - 7 files changed, 126 insertions(+), 25 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index 69f3919..a212ee6 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -11,6 +11,7 @@ import com.codenames.frontend.data.model.ChatLists import com.codenames.frontend.data.model.GameCard import com.codenames.frontend.data.model.GameState import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.screens.GameboardScreen import org.junit.Rule @@ -38,6 +39,8 @@ class GameboardScreenTest { currentHint = "EAGLE", currentTurn = PlayerRoles.BLUE_OPERATIVE, remainingGuesses = 3, + currentBlueFound = 0, + currentRedFound = 1, cards = cards, ), onHintChange = { _, _ -> }, @@ -71,6 +74,39 @@ class GameboardScreenTest { composeRule.onNodeWithText("Hint: EAGLE").assertIsDisplayed() } + @Test + fun spymasterCanOpenLobbyChat() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_SPYMASTER, + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + chatLists = + ChatLists( + lobbyMessages = + listOf( + ChatDomainModel( + sender = "Anna", + text = "Lobby message", + isFromMe = false, + ), + ), + ), + availableChatTabs = listOf(ChatTab.GLOBAL), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Chat").performClick() + composeRule.onNodeWithText("Global Chat").assertIsDisplayed() + composeRule.onNodeWithText("Anna").assertIsDisplayed() + composeRule.onNodeWithText("Lobby message").assertIsDisplayed() + } + @Test fun gameboardDisplaysTeamChatMessages() { composeRule.setContent { @@ -91,6 +127,7 @@ class GameboardScreenTest { ), ), ), + availableChatTabs = listOf(ChatTab.GLOBAL, ChatTab.TEAM), ), onHintChange = { _, _ -> }, onReveal = {}, @@ -102,4 +139,24 @@ class GameboardScreenTest { composeRule.onNodeWithText("Max").assertIsDisplayed() composeRule.onNodeWithText("Take Berlin").assertIsDisplayed() } + + @Test + fun operativeChatTabIsHiddenWhenNotAvailable() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + availableChatTabs = listOf(ChatTab.GLOBAL, ChatTab.TEAM), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Chat").performClick() + composeRule.onAllNodesWithText("Operatives").assertCountEquals(0) + } } diff --git a/app/src/main/java/com/codenames/frontend/data/model/GameState.kt b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt index d57e972..c1f6cba 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 @@ -1,5 +1,6 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.roles.PlayerRoles @@ -9,5 +10,8 @@ data class GameState( val currentTurn: PlayerRoles = PlayerRoles.NONE, val winner: Team? = null, val remainingGuesses: Int = 0, + val currentRedFound: Int = 0, + val currentBlueFound: Int = 0, val chatLists: ChatLists = ChatLists(), + val availableChatTabs: List = listOf(ChatTab.GLOBAL), ) diff --git a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt index 08d1922..e071b38 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.CardDto @@ -21,17 +22,22 @@ fun PlayerDto.toUi(): Player = role = role, team = team, isHost = isHost, - isReady = false, // if we add this functionality + isReady = false, ) -fun GameMessage.toGameState(): GameState = - GameState( +fun GameMessage.toGameState(): GameState { + val cards = cardList.map { it.toGameCard() } + + return GameState( currentHint = currentClue?.word ?: "", - cards = cardList.map { it.toGameCard() }, + cards = cards, currentTurn = getCurrentTurn(), winner = winner, remainingGuesses = currentClue?.guessAmount ?: 0, + currentRedFound = cards.count { it.type == CardType.RED && it.revealed }, + currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed }, ) +} fun CardDto.toGameCard(): GameCard = GameCard( 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 8cd172c..8ef73a6 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,7 +5,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController import com.codenames.frontend.data.model.enums.ChatTab +import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -27,10 +29,33 @@ fun GameScreenWrapper( val currentPlayer = lobbyState.players.firstOrNull { it.name == usernameState.username } val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() + val isOperative = + userRole == PlayerRoles.BLUE_OPERATIVE || userRole == PlayerRoles.RED_OPERATIVE + val sameTeamOperativeCount = + lobbyState.players.count { player -> + player.team == team && player.role == Role.OPERATIVE + } + + val availableChatTabs = + buildList { + add(ChatTab.GLOBAL) + + if (team != null) { + add(ChatTab.TEAM) + } + + if (isOperative && sameTeamOperativeCount > 1) { + add(ChatTab.OPERATIVES) + } + } GameboardScreen( userRole = userRole, - gameState = gameState.copy(chatLists = chatState), + gameState = + gameState.copy( + chatLists = chatState, + availableChatTabs = availableChatTabs, + ), onHintChange = { word, count -> if (lobbyCode.isNotBlank()) { gameViewModel.submitClue(lobbyCode, word, count) @@ -65,7 +90,7 @@ fun GameScreenWrapper( } ChatTab.OPERATIVES -> { - if (team != null) { + if (team != null && ChatTab.OPERATIVES in availableChatTabs) { gameViewModel.sendOperativeMessage( lobbyCode = lobbyCode, team = team.name, 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 842162a..fc59cea 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 @@ -81,8 +81,9 @@ fun GameboardScreen( val winner = gameState.winner val remainingGuesses = gameState.remainingGuesses val chatLists = gameState.chatLists - val currentRedFound = cards.count { it.type == CardType.RED && it.revealed } - val currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed } + val currentRedFound = gameState.currentRedFound + val currentBlueFound = gameState.currentBlueFound + val availableChatTabs = gameState.availableChatTabs var hintInput by rememberSaveable { mutableStateOf("") } var countInput by rememberSaveable { mutableStateOf("") } @@ -90,6 +91,13 @@ fun GameboardScreen( var isChatOpen by rememberSaveable { mutableStateOf(false) } var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } + val activeChatTab = + if (selectedChatTab in availableChatTabs) { + selectedChatTab + } else { + availableChatTabs.firstOrNull() ?: ChatTab.GLOBAL + } + val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val keyboardController = LocalSoftwareKeyboardController.current @@ -192,20 +200,15 @@ fun GameboardScreen( ) } - if (!isSpymaster && isChatOpen) { + if (availableChatTabs.isNotEmpty() && isChatOpen) { ChatWindow( chatInput = chatInput, messages = chatLists, - selectedTab = selectedChatTab, + selectedTab = activeChatTab, + availableTabs = availableChatTabs, onTabSelected = { selectedChatTab = it }, onChatInputChange = { chatInput = it }, - onSendClick = { tab -> - val trimmedMessage = chatInput.trim() - if (trimmedMessage.isNotBlank()) { - onSendChatMessage(tab, trimmedMessage) - chatInput = "" - } - }, + onSendClick = { tab, message -> onSendChatMessage(tab, message) }, modifier = Modifier .align(Alignment.Center) @@ -215,7 +218,7 @@ fun GameboardScreen( ) } - if (!isSpymaster) { + if (availableChatTabs.isNotEmpty()) { ChatToggleButton( isChatOpen = isChatOpen, onClick = { isChatOpen = !isChatOpen }, @@ -295,9 +298,10 @@ fun ChatWindow( chatInput: String, messages: ChatLists, selectedTab: ChatTab, + availableTabs: List, onTabSelected: (ChatTab) -> Unit, onChatInputChange: (String) -> Unit, - onSendClick: (ChatTab) -> Unit, + onSendClick: (ChatTab, String) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -313,7 +317,7 @@ fun ChatWindow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - ChatTab.entries.forEach { tab -> + availableTabs.forEach { tab -> AppButton( text = tab.title, onClick = { onTabSelected(tab) }, @@ -375,7 +379,13 @@ fun ChatWindow( AppButton( text = "Send", - onClick = { onSendClick(selectedTab) }, + onClick = { + val trimmedMessage = chatInput.trim() + if (trimmedMessage.isNotBlank()) { + onSendClick(selectedTab, trimmedMessage) + onChatInputChange("") + } + }, modifier = Modifier .width(92.dp) @@ -780,7 +790,10 @@ fun OfflineGameStateTestScreen() { 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 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 fad80bd..57ad110 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,6 @@ import com.codenames.frontend.data.model.toGameState import com.codenames.frontend.data.repository.ChatRepository import com.codenames.frontend.data.repository.GameRepository import com.codenames.frontend.network.dto.GameMessage -import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel @@ -52,7 +51,6 @@ class GameViewModel isHost: Boolean = false, ) { job?.cancel() - _chatState.value = ChatLists() job = viewModelScope.launch { @@ -60,7 +58,6 @@ class GameViewModel try { client.connectStomp() - client.sendReconnectMessage(WebSocketJoinMessage(username, lobbyCode)) Log.d("GameViewModel", "Connection successful") 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 fc47088..d6f7042 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -75,7 +75,6 @@ class GameViewModelTest { chatRepository = mockk(relaxed = true) gameRepository = mockk(relaxed = true) - coEvery { client.sendReconnectMessage(any()) } just Runs every { chatRepository.observeChat(any(), any()) } returns emptyFlow() viewModel = GameViewModel(client, chatRepository, gameRepository) From 4c411ee2beea71e094b8cf69bc8d1c504e6f7632 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Tue, 19 May 2026 20:11:25 +0200 Subject: [PATCH 116/121] fixed sonar issue --- .../frontend/ui/screens/GameScreenWrapper.kt | 209 +++++++++++++----- 1 file changed, 150 insertions(+), 59 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 8ef73a6..0d92ec1 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 @@ -4,8 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController +import com.codenames.frontend.data.model.Player import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.viewmodel.GameViewModel @@ -24,30 +26,13 @@ fun GameScreenWrapper( val gameState by gameViewModel.uiState.collectAsState() val chatState by gameViewModel.chatState.collectAsState() val usernameState by sessionViewModel.username.collectAsState() - val userRole = lobbyViewModel.getRoleForUser(usernameState.username) - val currentPlayer = lobbyState.players.firstOrNull { it.name == usernameState.username } + val username = usernameState.username + val userRole = lobbyViewModel.getRoleForUser(username) + val currentPlayer = lobbyState.players.firstOrNull { it.name == username } val team = currentPlayer?.team val lobbyCode = lobbyState.lobbyCode.orEmpty() - val isOperative = - userRole == PlayerRoles.BLUE_OPERATIVE || userRole == PlayerRoles.RED_OPERATIVE - val sameTeamOperativeCount = - lobbyState.players.count { player -> - player.team == team && player.role == Role.OPERATIVE - } - - val availableChatTabs = - buildList { - add(ChatTab.GLOBAL) - - if (team != null) { - add(ChatTab.TEAM) - } - - if (isOperative && sameTeamOperativeCount > 1) { - add(ChatTab.OPERATIVES) - } - } + val availableChatTabs = getAvailableChatTabs(userRole, lobbyState.players, team) GameboardScreen( userRole = userRole, @@ -57,52 +42,158 @@ fun GameScreenWrapper( availableChatTabs = availableChatTabs, ), onHintChange = { word, count -> - if (lobbyCode.isNotBlank()) { - gameViewModel.submitClue(lobbyCode, word, count) - } + sendHintIfPossible( + lobbyCode = lobbyCode, + word = word, + count = count, + gameViewModel = gameViewModel, + ) }, onReveal = { // TODO: Send guess through GameViewModel once backend endpoint exists. }, onSendChatMessage = { tab, message -> - if (lobbyCode.isBlank()) { - return@GameboardScreen - } - - when (tab) { - ChatTab.GLOBAL -> { - gameViewModel.sendLobbyMessage( - lobbyCode = lobbyCode, - username = usernameState.username, - content = message, - ) - } - - ChatTab.TEAM -> { - if (team != null) { - gameViewModel.sendTeamMessage( - lobbyCode = lobbyCode, - team = team.name, - username = usernameState.username, - content = message, - ) - } - } - - ChatTab.OPERATIVES -> { - if (team != null && ChatTab.OPERATIVES in availableChatTabs) { - gameViewModel.sendOperativeMessage( - lobbyCode = lobbyCode, - team = team.name, - username = usernameState.username, - content = message, - ) - } - } - } + sendChatMessage( + tab = tab, + message = message, + lobbyCode = lobbyCode, + username = username, + team = team, + availableChatTabs = availableChatTabs, + gameViewModel = gameViewModel, + ) }, onSettingsClick = { navController.navigate(Screen.Settings.route) }, ) } + +private fun getAvailableChatTabs( + userRole: PlayerRoles, + players: List, + team: Team?, +): List { + val tabs = mutableListOf(ChatTab.GLOBAL) + + if (team != null) { + tabs.add(ChatTab.TEAM) + } + + if (canUseOperativesChat(userRole, players, team)) { + tabs.add(ChatTab.OPERATIVES) + } + + return tabs +} + +private fun canUseOperativesChat( + userRole: PlayerRoles, + players: List, + team: Team?, +): Boolean { + if (team == null) { + return false + } + + val isOperative = + userRole == PlayerRoles.BLUE_OPERATIVE || userRole == PlayerRoles.RED_OPERATIVE + val sameTeamOperativeCount = + players.count { player -> + player.team == team && player.role == Role.OPERATIVE + } + + return isOperative && sameTeamOperativeCount > 1 +} + +private fun sendHintIfPossible( + lobbyCode: String, + word: String, + count: Int, + gameViewModel: GameViewModel, +) { + if (lobbyCode.isNotBlank()) { + gameViewModel.submitClue(lobbyCode, word, count) + } +} + +private fun sendChatMessage( + tab: ChatTab, + message: String, + lobbyCode: String, + username: String, + team: Team?, + availableChatTabs: List, + gameViewModel: GameViewModel, +) { + if (lobbyCode.isBlank()) { + return + } + + when (tab) { + ChatTab.GLOBAL -> + gameViewModel.sendLobbyMessage( + lobbyCode = lobbyCode, + username = username, + content = message, + ) + + ChatTab.TEAM -> + sendTeamChatMessage( + lobbyCode = lobbyCode, + username = username, + team = team, + message = message, + gameViewModel = gameViewModel, + ) + + ChatTab.OPERATIVES -> + sendOperativesChatMessage( + lobbyCode = lobbyCode, + username = username, + team = team, + message = message, + availableChatTabs = availableChatTabs, + gameViewModel = gameViewModel, + ) + } +} + +private fun sendTeamChatMessage( + lobbyCode: String, + username: String, + team: Team?, + message: String, + gameViewModel: GameViewModel, +) { + if (team == null) { + return + } + + gameViewModel.sendTeamMessage( + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = message, + ) +} + +private fun sendOperativesChatMessage( + lobbyCode: String, + username: String, + team: Team?, + message: String, + availableChatTabs: List, + gameViewModel: GameViewModel, +) { + if (team == null || ChatTab.OPERATIVES !in availableChatTabs) { + return + } + + gameViewModel.sendOperativeMessage( + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = message, + ) +} From 9e8d79e07997b4c924e2f4326468df5547fa1800 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Wed, 20 May 2026 15:57:29 +0200 Subject: [PATCH 117/121] fix: refined chat state wiring and tab visibility --- .../frontend/ui/screens/GameScreenWrapper.kt | 152 +----------------- .../frontend/viewmodel/GameViewModel.kt | 47 ++++++ .../frontend/viewmodel/LobbyViewModel.kt | 35 ++++ 3 files changed, 88 insertions(+), 146 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 0d92ec1..6329512 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 @@ -4,12 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import com.codenames.frontend.data.model.Player -import com.codenames.frontend.data.model.enums.ChatTab -import com.codenames.frontend.data.model.enums.Role -import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.navigation.Screen -import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -28,11 +23,11 @@ fun GameScreenWrapper( val usernameState by sessionViewModel.username.collectAsState() val username = usernameState.username - val userRole = lobbyViewModel.getRoleForUser(username) + val lobbyCode = lobbyState.lobbyCode.orEmpty() val currentPlayer = lobbyState.players.firstOrNull { it.name == username } val team = currentPlayer?.team - val lobbyCode = lobbyState.lobbyCode.orEmpty() - val availableChatTabs = getAvailableChatTabs(userRole, lobbyState.players, team) + val userRole = lobbyViewModel.getRoleForUser(username) + val availableChatTabs = lobbyViewModel.getAvailableChatTabsForUser(username) GameboardScreen( userRole = userRole, @@ -42,25 +37,19 @@ fun GameScreenWrapper( availableChatTabs = availableChatTabs, ), onHintChange = { word, count -> - sendHintIfPossible( - lobbyCode = lobbyCode, - word = word, - count = count, - gameViewModel = gameViewModel, - ) + gameViewModel.submitClue(lobbyCode, word, count) }, onReveal = { // TODO: Send guess through GameViewModel once backend endpoint exists. }, onSendChatMessage = { tab, message -> - sendChatMessage( + gameViewModel.sendChatMessage( tab = tab, - message = message, lobbyCode = lobbyCode, username = username, team = team, + content = message, availableChatTabs = availableChatTabs, - gameViewModel = gameViewModel, ) }, onSettingsClick = { @@ -68,132 +57,3 @@ fun GameScreenWrapper( }, ) } - -private fun getAvailableChatTabs( - userRole: PlayerRoles, - players: List, - team: Team?, -): List { - val tabs = mutableListOf(ChatTab.GLOBAL) - - if (team != null) { - tabs.add(ChatTab.TEAM) - } - - if (canUseOperativesChat(userRole, players, team)) { - tabs.add(ChatTab.OPERATIVES) - } - - return tabs -} - -private fun canUseOperativesChat( - userRole: PlayerRoles, - players: List, - team: Team?, -): Boolean { - if (team == null) { - return false - } - - val isOperative = - userRole == PlayerRoles.BLUE_OPERATIVE || userRole == PlayerRoles.RED_OPERATIVE - val sameTeamOperativeCount = - players.count { player -> - player.team == team && player.role == Role.OPERATIVE - } - - return isOperative && sameTeamOperativeCount > 1 -} - -private fun sendHintIfPossible( - lobbyCode: String, - word: String, - count: Int, - gameViewModel: GameViewModel, -) { - if (lobbyCode.isNotBlank()) { - gameViewModel.submitClue(lobbyCode, word, count) - } -} - -private fun sendChatMessage( - tab: ChatTab, - message: String, - lobbyCode: String, - username: String, - team: Team?, - availableChatTabs: List, - gameViewModel: GameViewModel, -) { - if (lobbyCode.isBlank()) { - return - } - - when (tab) { - ChatTab.GLOBAL -> - gameViewModel.sendLobbyMessage( - lobbyCode = lobbyCode, - username = username, - content = message, - ) - - ChatTab.TEAM -> - sendTeamChatMessage( - lobbyCode = lobbyCode, - username = username, - team = team, - message = message, - gameViewModel = gameViewModel, - ) - - ChatTab.OPERATIVES -> - sendOperativesChatMessage( - lobbyCode = lobbyCode, - username = username, - team = team, - message = message, - availableChatTabs = availableChatTabs, - gameViewModel = gameViewModel, - ) - } -} - -private fun sendTeamChatMessage( - lobbyCode: String, - username: String, - team: Team?, - message: String, - gameViewModel: GameViewModel, -) { - if (team == null) { - return - } - - gameViewModel.sendTeamMessage( - lobbyCode = lobbyCode, - team = team.name, - username = username, - content = message, - ) -} - -private fun sendOperativesChatMessage( - lobbyCode: String, - username: String, - team: Team?, - message: String, - availableChatTabs: List, - gameViewModel: GameViewModel, -) { - if (team == null || ChatTab.OPERATIVES !in availableChatTabs) { - return - } - - gameViewModel.sendOperativeMessage( - lobbyCode = lobbyCode, - team = team.name, - username = username, - content = message, - ) -} 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 57ad110..68841a1 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -6,6 +6,7 @@ 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 @@ -153,6 +154,10 @@ class GameViewModel word: String, count: Int, ) { + if (lobbyCode.isBlank()) { + return + } + val turn = uiState.value.currentTurn if (turn != PlayerRoles.BLUE_SPYMASTER && turn != PlayerRoles.RED_SPYMASTER) return @@ -166,6 +171,48 @@ 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 { 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 214f077..c99379b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.LobbyUiState import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.model.toLobbyState @@ -171,6 +172,40 @@ class LobbyViewModel } } + fun getAvailableChatTabsForUser(username: String): List { + val player = _state.value.players.firstOrNull { it.name == username } + val team = player?.team + val role = player?.role + + val tabs = mutableListOf(ChatTab.GLOBAL) + + if (team != null) { + tabs.add(ChatTab.TEAM) + } + + if (canUseOperativesChat(team = team, role = role)) { + tabs.add(ChatTab.OPERATIVES) + } + + return tabs + } + + private fun canUseOperativesChat( + team: Team?, + role: Role?, + ): Boolean { + if (team == null || role != Role.OPERATIVE) { + return false + } + + val sameTeamOperativeCount = + _state.value.players.count { player -> + player.team == team && player.role == Role.OPERATIVE + } + + return sameTeamOperativeCount > 1 + } + fun getIsHost(username: String): Boolean { val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false return player.isHost From c29106a3ada2575902ffc19a74c08fa4f30fe6f3 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Wed, 20 May 2026 16:45:16 +0200 Subject: [PATCH 118/121] fix: chat window size --- .../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 fc59cea..6282d95 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 @@ -212,9 +212,9 @@ fun GameboardScreen( modifier = Modifier .align(Alignment.Center) - .padding(end = 24.dp, bottom = 96.dp) + .padding(end = 24.dp, bottom = 12.dp) .width(420.dp) - .fillMaxHeight(0.78f), + .fillMaxHeight(0.90f), ) } From a47a488f262b3bbdc6cd317b341ba4bf51486f47 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Wed, 20 May 2026 17:21:09 +0200 Subject: [PATCH 119/121] added unit tests --- .../frontend/viewmodel/GameViewModelTest.kt | 93 ++++++++++++++ .../frontend/viewmodel/LobbyViewModelTest.kt | 114 ++++++++++++++++++ 2 files changed, 207 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 d6f7042..1b311aa 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -4,6 +4,7 @@ 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 @@ -431,4 +432,96 @@ 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 { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitClue("", "EAGLE", 2) + advanceUntilIdle() + + coVerify(exactly = 0) { + client.sendClue(any(), any(), any(), any()) + } + } } diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt index d5d8fce..55707f6 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -1,6 +1,7 @@ package com.codenames.frontend.viewmodel import android.util.Log +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.LobbyRepository @@ -1030,4 +1031,117 @@ class LobbyViewModelTest { viewModel.state.value.error, ) } + + @Test + fun getAvailableChatTabsForUser_unknownUser_returnsGlobalOnly() { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val result = viewModel.getAvailableChatTabsForUser("Unknown") + + assertEquals(listOf(ChatTab.GLOBAL), result) + } + + @Test + fun getAvailableChatTabsForUser_playerWithoutTeam_returnsGlobalOnly() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = listOf(PlayerDto(username = "Alice", role = null, team = null, isHost = false)), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals(listOf(ChatTab.GLOBAL), viewModel.getAvailableChatTabsForUser("Alice")) + } + + @Test + fun getAvailableChatTabsForUser_spymasterWithTeam_returnsGlobalAndTeam() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = listOf(PlayerDto("Alice", Role.SPYMASTER, Team.BLUE, false)), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals(listOf(ChatTab.GLOBAL, ChatTab.TEAM), viewModel.getAvailableChatTabsForUser("Alice")) + } + + @Test + fun getAvailableChatTabsForUser_singleOperative_returnsGlobalAndTeam() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto("Alice", Role.OPERATIVE, Team.BLUE, false), + PlayerDto("Bob", Role.SPYMASTER, Team.BLUE, false), + ), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals(listOf(ChatTab.GLOBAL, ChatTab.TEAM), viewModel.getAvailableChatTabsForUser("Alice")) + } + + @Test + fun getAvailableChatTabsForUser_multipleSameTeamOperatives_returnsAllTabs() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto("Alice", Role.OPERATIVE, Team.BLUE, false), + PlayerDto("Bob", Role.OPERATIVE, Team.BLUE, false), + ), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals( + listOf(ChatTab.GLOBAL, ChatTab.TEAM, ChatTab.OPERATIVES), + viewModel.getAvailableChatTabsForUser("Alice"), + ) + } } From b997804f3d523dd30f5ddfde886858296afb29b1 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 20 May 2026 22:06:17 +0200 Subject: [PATCH 120/121] fix: move chat button to enable hint giving + remove ip adresses --- .../network/provider/RetrofitProvider.kt | 2 +- .../network/websocket/GameWebSocketHandler.kt | 2 +- .../frontend/ui/screens/GameboardScreen.kt | 23 ++++++++++--------- 3 files changed, 14 insertions(+), 13 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 c18e691..3d1b1f5 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://10.0.2.2:8080/" +const val BASE_URL = "http://localhost:8080/" @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt index ea32035..41a4800 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt @@ -17,7 +17,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject import javax.inject.Singleton -const val BASE_URL = "ws://10.0.2.2:8080/ws-fallback" +const val BASE_URL = "ws://localhost:8080/ws-fallback" @Singleton class GameWebSocketHandler 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 6282d95..f9c4bff 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,16 @@ fun GameboardScreen( remainingGuesses = remainingGuesses, ) + if (availableChatTabs.isNotEmpty()) { + ChatToggleButton( + isChatOpen = isChatOpen, + onClick = { isChatOpen = !isChatOpen }, + modifier = + Modifier + .padding(end = 24.dp, bottom = 24.dp), + ) + } + Spacer(modifier = Modifier.height(8.dp)) Row( @@ -218,16 +228,7 @@ fun GameboardScreen( ) } - if (availableChatTabs.isNotEmpty()) { - ChatToggleButton( - isChatOpen = isChatOpen, - onClick = { isChatOpen = !isChatOpen }, - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 24.dp), - ) - } + onSettingsClick?.let { openSettings -> SettingsCornerButton( @@ -558,7 +559,7 @@ fun HintSection( AppTextField( value = hintInput, onValueChange = onInputChange, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(0.8f), state = AppTextFieldState( label = "HINT", From 5bc5c76d5004213786104287a706cbda429cbae9 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Wed, 20 May 2026 22:26:36 +0200 Subject: [PATCH 121/121] format: formatted code --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 2 -- 1 file changed, 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 f9c4bff..e8afdee 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt @@ -228,8 +228,6 @@ fun GameboardScreen( ) } - - onSettingsClick?.let { openSettings -> SettingsCornerButton( onClick = openSettings,