feat: add dark mode with toggle in top bar

- Created UnoTheme.kt with light/dark color schemes
- Added dark mode toggle (moon/sun icon) in GameInfoBar
- Wrapped all entry points (Desktop, Android, Web) with UnoTheme
- Fixed Web entry point to use CanvasBasedWindow
- Fixed Math.random() for WASM compatibility
This commit is contained in:
2026-08-02 18:52:25 +02:00
parent a7fb6ff94c
commit 8732c4142c
5 changed files with 140 additions and 68 deletions
@@ -13,6 +13,7 @@ import androidx.compose.ui.unit.dp
import com.uno.game.GameEngine import com.uno.game.GameEngine
import com.uno.game.model.* import com.uno.game.model.*
import com.uno.shared.UnoGameScreen import com.uno.shared.UnoGameScreen
import com.uno.shared.UnoTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -20,7 +21,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme { UnoTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
UnoApp() UnoApp()
} }
@@ -13,6 +13,7 @@ import androidx.compose.ui.window.application
import com.uno.game.GameEngine import com.uno.game.GameEngine
import com.uno.game.model.* import com.uno.game.model.*
import com.uno.shared.UnoGameScreen import com.uno.shared.UnoGameScreen
import com.uno.shared.UnoTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
fun main() = application { fun main() = application {
@@ -49,31 +50,33 @@ fun UnoDesktopApp() {
} }
} }
if (!gameStarted) { UnoTheme {
DesktopStartScreen(onStartGame = { playerCount -> if (!gameStarted) {
gameState = engine.startGame(playerCount) DesktopStartScreen(onStartGame = { playerCount ->
gameStarted = true gameState = engine.startGame(playerCount)
}) gameStarted = true
} else { })
UnoGameScreen( } else {
state = gameState, UnoGameScreen(
onPlayCard = { card, color -> state = gameState,
gameState = engine.playCard(gameState, 0, card, color) onPlayCard = { card, color ->
}, gameState = engine.playCard(gameState, 0, card, color)
onDrawCard = { },
gameState = engine.drawCard(gameState, 0) onDrawCard = {
}, gameState = engine.drawCard(gameState, 0)
onCallUno = { gameState = engine.callUno(gameState, 0) } },
) onCallUno = { gameState = engine.callUno(gameState, 0) }
if (gameState.isFinished) {
DesktopGameOverScreen(
winner = gameState.winner,
onPlayAgain = {
gameState = engine.startGame()
gameStarted = false
}
) )
if (gameState.isFinished) {
DesktopGameOverScreen(
winner = gameState.winner,
onPlayAgain = {
gameState = engine.startGame()
gameStarted = false
}
)
}
} }
} }
} }
@@ -63,7 +63,7 @@ private class FlyingCardState {
animatableX = Animatable(fromX) animatableX = Animatable(fromX)
animatableY = Animatable(fromY) animatableY = Animatable(fromY)
animatableScale = Animatable(0.3f) animatableScale = Animatable(0.3f)
animatableRotation = Animatable((Math.random() * 60 - 30).toFloat()) animatableRotation = Animatable((kotlin.random.Random.nextFloat() * 60 - 30).toFloat())
coroutineScope { coroutineScope {
launch { launch {
@@ -274,6 +274,7 @@ fun UnoGameScreen(
var previousLastAction by remember { mutableStateOf(state.lastAction) } var previousLastAction by remember { mutableStateOf(state.lastAction) }
var previousTopCard by remember { mutableStateOf(state.deck.topCard) } var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) } var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) }
var isDarkMode by remember { mutableStateOf(false) }
val flyingState = rememberFlyingCardState() val flyingState = rememberFlyingCardState()
@@ -317,14 +318,15 @@ fun UnoGameScreen(
previousLastAction = state.lastAction previousLastAction = state.lastAction
} }
Box(modifier = Modifier.fillMaxSize()) { UnoTheme(darkTheme = isDarkMode) {
Column( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier Column(
.fillMaxSize() modifier = Modifier
.padding(16.dp), .fillMaxSize()
horizontalAlignment = Alignment.CenterHorizontally .padding(16.dp),
) { horizontalAlignment = Alignment.CenterHorizontally
GameInfoBar(state) ) {
GameInfoBar(state, isDarkMode, { isDarkMode = it })
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OpponentHands(state) OpponentHands(state)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -417,12 +419,13 @@ fun UnoGameScreen(
} }
) )
} }
}
} }
// region --- GameInfoBar --- // region --- GameInfoBar ---
@Composable @Composable
private fun GameInfoBar(state: GameState) { private fun GameInfoBar(state: GameState, isDarkMode: Boolean, onDarkModeToggle: (Boolean) -> Unit) {
val directionRotation by animateFloatAsState( val directionRotation by animateFloatAsState(
targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f, targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f,
animationSpec = tween(500, easing = FastOutSlowInEasing), animationSpec = tween(500, easing = FastOutSlowInEasing),
@@ -462,6 +465,13 @@ private fun GameInfoBar(state: GameState) {
) )
} }
} }
// Dark mode toggle
IconButton(onClick = { onDarkModeToggle(!isDarkMode) }) {
Text(
text = if (isDarkMode) "\u2600\uFE0F" else "\uD83C\uDF19",
style = MaterialTheme.typography.bodyLarge
)
}
} }
} }
} }
@@ -0,0 +1,57 @@
package com.uno.shared
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2),
onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB),
onPrimaryContainer = Color(0xFF0D47A1),
secondary = Color(0xFFFF6D00),
onSecondary = Color.White,
secondaryContainer = Color(0xFFFFE0B2),
onSecondaryContainer = Color(0xFFE65100),
background = Color(0xFFF5F5F5),
onBackground = Color(0xFF1C1B1F),
surface = Color.White,
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
error = Color(0xFFE53935),
onError = Color.White,
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF0D47A1),
primaryContainer = Color(0xFF1565C0),
onPrimaryContainer = Color(0xFFBBDEFB),
secondary = Color(0xFFFFB74D),
onSecondary = Color(0xFF3E2723),
secondaryContainer = Color(0xFFE65100),
onSecondaryContainer = Color(0xFFFFE0B2),
background = Color(0xFF121212),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE6E1E5),
surfaceVariant = Color(0xFF2C2C2C),
onSurfaceVariant = Color(0xFFCAC4D0),
error = Color(0xFFEF5350),
onError = Color(0xFF601010),
)
@Composable
fun UnoTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -4,26 +4,25 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.CanvasBasedWindow
import com.uno.game.GameEngine import com.uno.game.GameEngine
import com.uno.game.model.* import com.uno.game.model.*
import com.uno.shared.UnoGameScreen import com.uno.shared.UnoGameScreen
import kotlinx.browser.document import com.uno.shared.UnoTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
val root = document.getElementById("root") as HTMLElement CanvasBasedWindow(canvasElementId = "ComposeTarget") {
root.innerHTML = "" UnoApp()
val container = document.createElement("div") }
container.id = "app"
root.appendChild(container)
composeWebRoot()
} }
@Composable @Composable
fun composeWebRoot() { fun UnoApp() {
val engine = remember { GameEngine() } val engine = remember { GameEngine() }
var gameState by remember { mutableStateOf(engine.startGame()) } var gameState by remember { mutableStateOf(engine.startGame()) }
var gameStarted by remember { mutableStateOf(false) } var gameStarted by remember { mutableStateOf(false) }
@@ -45,31 +44,33 @@ fun composeWebRoot() {
} }
} }
if (!gameStarted) { UnoTheme {
WebStartScreen(onStartGame = { playerCount -> if (!gameStarted) {
gameState = engine.startGame(playerCount) WebStartScreen(onStartGame = { playerCount ->
gameStarted = true gameState = engine.startGame(playerCount)
}) gameStarted = true
} else { })
UnoGameScreen( } else {
state = gameState, UnoGameScreen(
onPlayCard = { card, color -> state = gameState,
gameState = engine.playCard(gameState, 0, card, color) onPlayCard = { card, color ->
}, gameState = engine.playCard(gameState, 0, card, color)
onDrawCard = { },
gameState = engine.drawCard(gameState, 0) onDrawCard = {
}, gameState = engine.drawCard(gameState, 0)
onCallUno = { gameState = engine.callUno(gameState, 0) } },
) onCallUno = { gameState = engine.callUno(gameState, 0) }
if (gameState.isFinished) {
WebGameOverScreen(
winner = gameState.winner,
onPlayAgain = {
gameState = engine.startGame()
gameStarted = false
}
) )
if (gameState.isFinished) {
WebGameOverScreen(
winner = gameState.winner,
onPlayAgain = {
gameState = engine.startGame()
gameStarted = false
}
)
}
} }
} }
} }