diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b86273d..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..3b0be22 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ea29f5..c62e0b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,10 +11,10 @@ android { defaultConfig { applicationId = "com.atridad.magiccounter" - minSdk = 35 + minSdk = 31 targetSdk = 36 versionCode = 2 - versionName = "1.1.0" + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -28,13 +28,22 @@ android { ) } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { jvmTarget = "17" } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + buildFeatures { compose = true } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt index 4fc59c8..75c0f99 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt @@ -12,11 +12,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Button import androidx.compose.material3.AlertDialog import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -30,13 +27,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import kotlinx.coroutines.delay import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.atridad.magiccounter.ui.screens.GameScreen import com.atridad.magiccounter.ui.screens.SetupScreen @@ -46,11 +46,10 @@ import com.atridad.magiccounter.ui.settings.AppSettingsViewModel import com.atridad.magiccounter.ui.settings.ThemeMode import com.atridad.magiccounter.ui.settings.MatchRecord import com.atridad.magiccounter.ui.theme.MagicCounterTheme +import com.atridad.magiccounter.ui.theme.CustomIcons import androidx.activity.compose.BackHandler -import androidx.compose.material3.HorizontalDivider import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.layout.Spacer private sealed class Screen { data object Home : Screen() @@ -67,8 +66,7 @@ fun MagicCounterApp() { val theme = settingsVm.themeMode.collectAsState() val historyState = settingsVm.matchHistory.collectAsState() - // State for clear history confirmation - var showClearConfirm by remember { mutableStateOf(false) } + MagicCounterTheme(themeMode = theme.value) { Scaffold( @@ -77,18 +75,13 @@ fun MagicCounterApp() { title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) }, navigationIcon = { if (screenStack.size > 1) { - IconButton(onClick = { screenStack.removeLast() }) { + IconButton(onClick = { screenStack.removeAt(screenStack.lastIndex) }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } }, actions = { - // Show Clear History icon only on home screen - if (screenStack.last() is Screen.Home) { - IconButton(onClick = { showClearConfirm = true }) { - Icon(Icons.Default.Clear, contentDescription = "Clear history") - } - } + IconButton(onClick = { screenStack.add(Screen.Settings) }) { Icon(Icons.Default.Settings, contentDescription = "App settings") } @@ -102,29 +95,40 @@ fun MagicCounterApp() { FloatingActionButton( onClick = { screenStack.add(Screen.Setup) } ) { - Icon(Icons.Default.Add, contentDescription = "New game") + Icon(Icons.Default.PlayArrow, contentDescription = "Start new game") } } } ) { paddingValues -> val currentScreen = screenStack.last() - BackHandler(enabled = screenStack.size > 1) { screenStack.removeLast() } + BackHandler(enabled = screenStack.size > 1) { screenStack.removeAt(screenStack.lastIndex) } when (currentScreen) { is Screen.Home -> HomeScreen( modifier = Modifier .padding(paddingValues) + .fillMaxWidth() .fillMaxSize(), history = historyState.value, onResume = { record -> screenStack.add(Screen.Game(record.id)) }, + onStopGame = { record -> + // Stop the active game by marking it as stopped + val current = historyState.value + val idx = current.indexOfFirst { it.id == record.id } + if (idx >= 0) { + val stoppedState = current[idx].state.copy(stopped = true) + val updatedRec = current[idx].copy( + state = stoppedState, + lastUpdatedEpochMs = System.currentTimeMillis(), + ongoing = false, + winnerPlayerId = null + ) + val newList = current.toMutableList().apply { set(idx, updatedRec) } + settingsVm.saveHistory(newList) + } + }, onDelete = { id -> val newList = historyState.value.filterNot { it.id == id } settingsVm.saveHistory(newList) - }, - showClearConfirm = showClearConfirm, - onClearConfirm = { showClearConfirm = false }, - onClear = { - settingsVm.saveHistory(emptyList()) - showClearConfirm = false } ) is Screen.Setup -> SetupScreen( @@ -132,6 +136,13 @@ fun MagicCounterApp() { .padding(paddingValues) .fillMaxSize(), onStart = { name, state -> + // Check if there's already an active game + if (historyState.value.any { it.ongoing }) { + // Navigate back to home screen if a game is already active + screenStack.removeAt(screenStack.lastIndex) + return@SetupScreen + } + // Create and persist a new MatchRecord val newId = java.util.UUID.randomUUID().toString() val now = System.currentTimeMillis() @@ -147,7 +158,7 @@ fun MagicCounterApp() { val updated = historyState.value.toMutableList().apply { add(0, record) } settingsVm.saveHistory(updated) // Replace Setup with the new Game screen (pop then push) - if (screenStack.isNotEmpty()) screenStack.removeLast() + if (screenStack.isNotEmpty()) screenStack.removeAt(screenStack.lastIndex) screenStack.add(Screen.Game(newId, state)) } ) @@ -157,7 +168,7 @@ fun MagicCounterApp() { val record = historyState.value.firstOrNull { it.id == id } val stateForGame = record?.state ?: bootState if (stateForGame == null) { - screenStack.removeLast() + screenStack.removeAt(screenStack.lastIndex) } else { GameScreen( modifier = Modifier @@ -186,6 +197,30 @@ fun MagicCounterApp() { val newList = current.toMutableList().apply { set(idx, updatedRec) } settingsVm.saveHistory(newList) } + }, + onStop = { stoppedState -> + val current = historyState.value + val idx = current.indexOfFirst { it.id == id } + if (idx >= 0) { + val updatedRec = current[idx].copy( + state = stoppedState, + lastUpdatedEpochMs = System.currentTimeMillis(), + ongoing = false, + winnerPlayerId = null + ) + val newList = current.toMutableList().apply { set(idx, updatedRec) } + settingsVm.saveHistory(newList) + // Navigate back to home screen + screenStack.removeAt(screenStack.lastIndex) + } + }, + onDelete = { + // Delete the current game + val current = historyState.value + val newList = current.filterNot { it.id == id } + settingsVm.saveHistory(newList) + // Navigate back to home screen + screenStack.removeAt(screenStack.lastIndex) } ) } @@ -199,23 +234,7 @@ fun MagicCounterApp() { ) } - // Global clear history confirmation dialog - if (showClearConfirm) { - AlertDialog( - onDismissRequest = { showClearConfirm = false }, - title = { Text("Clear history?") }, - text = { Text("This will remove all matches. This action cannot be undone.") }, - confirmButton = { - TextButton(onClick = { - settingsVm.saveHistory(emptyList()) - showClearConfirm = false - }) { Text("Clear") } - }, - dismissButton = { - TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } - } - ) - } + } } } @@ -260,95 +279,57 @@ private fun HomeScreen( modifier: Modifier, history: List, onResume: (MatchRecord) -> Unit, - onDelete: (String) -> Unit, - showClearConfirm: Boolean, - onClearConfirm: () -> Unit, - onClear: () -> Unit + onStopGame: (MatchRecord) -> Unit, + onDelete: (String) -> Unit ) { - Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - var pendingDeleteId by remember { mutableStateOf(null) } - - // Only show action buttons if there are ongoing games - if (history.any { it.ongoing }) { + var pendingDeleteId by remember { mutableStateOf(null) } + + if (history.isEmpty()) { + // Empty state + androidx.compose.foundation.layout.Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - "Quick Actions", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + Icon( + Icons.Default.History, + contentDescription = "Empty history", + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "No games yet", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Start your first Magic: The Gathering game to begin tracking life totals and more.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - androidx.compose.material3.OutlinedButton( - onClick = { onResume(history.first { it.ongoing }) }, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.PlayArrow, contentDescription = null) - Spacer(modifier = Modifier.padding(4.dp)) - Text("Resume Game") - } - androidx.compose.material3.OutlinedButton( - onClick = { pendingDeleteId = history.first { it.ongoing }.id }, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.Delete, contentDescription = null) - Spacer(modifier = Modifier.padding(4.dp)) - Text("Delete Game") - } - } } } - - if (history.isEmpty()) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - Icons.Default.History, - contentDescription = "Empty history", - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "No games yet", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "Start your first Magic: The Gathering game to begin tracking life totals and more.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - } - } - } else { - LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + } else { + // Games list + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Active game at the top (if any) + val activeGame = history.firstOrNull { it.ongoing } + if (activeGame != null) { item { - Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 12.dp)) { - Text( - "Ongoing Games", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) - } - } - items(history.filter { it.ongoing }, key = { it.id }) { rec -> androidx.compose.material3.Card( modifier = Modifier.fillMaxWidth(), colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + containerColor = MaterialTheme.colorScheme.primary + ), + onClick = { onResume(activeGame) } ) { Row( modifier = Modifier.fillMaxWidth().padding(16.dp), @@ -357,70 +338,97 @@ private fun HomeScreen( ) { Column(modifier = Modifier.weight(1f)) { Text( - rec.name, + "Active Game", style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onPrimary ) Text( - "Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + activeGame.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimary + ) + GameDuration( + startTime = activeGame.startedAtEpochMs, + color = MaterialTheme.colorScheme.onPrimary ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - IconButton(onClick = { onResume(rec) }) { - Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") - } - IconButton(onClick = { pendingDeleteId = rec.id }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") - } + androidx.compose.material3.FilledTonalIconButton( + onClick = { onStopGame(activeGame) }, + modifier = Modifier.size(56.dp), + colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon( + CustomIcons.Stop(MaterialTheme.colorScheme.onError), + contentDescription = "Stop game", + modifier = Modifier.size(24.dp) + ) } } } } + } - item { - Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp)) { - Text( - "Finished Games", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) - } - } - items(history.filter { !it.ongoing }, key = { it.id }) { rec -> - androidx.compose.material3.Card( - modifier = Modifier.fillMaxWidth(), - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) + // Past games section + val pastGames = history.filter { !it.ongoing } + items(pastGames, key = { it.id }) { rec -> + androidx.compose.material3.Card( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + onClick = { onResume(rec) } + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - rec.name, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: "" - Text( - "Finished • $winner", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + Column(modifier = Modifier.weight(1f)) { + Text( + rec.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val status = if (rec.state.stopped) "Stopped" else "Finished" + val winner = rec.winnerPlayerId?.let { " • Winner: Player ${it + 1}" } ?: "" + val statusText = if (winner.isNotEmpty()) "$status$winner" else status + Text( + statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.FilledTonalIconButton( + onClick = { onResume(rec) }, + modifier = Modifier.size(40.dp), + colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary ) + ) { + Icon( + Icons.Default.Visibility, + contentDescription = "View match", + modifier = Modifier.size(20.dp) + ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - IconButton(onClick = { onResume(rec) }) { - Icon(Icons.Default.Visibility, contentDescription = "View match") - } - IconButton(onClick = { pendingDeleteId = rec.id }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") - } + androidx.compose.material3.FilledTonalIconButton( + onClick = { pendingDeleteId = rec.id }, + modifier = Modifier.size(40.dp), + colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(20.dp) + ) } } } @@ -444,20 +452,36 @@ private fun HomeScreen( dismissButton = { TextButton(onClick = { pendingDeleteId = null }) { Text("Cancel") } } ) } - - // Confirm clear dialog - if (showClearConfirm) { - AlertDialog( - onDismissRequest = { onClearConfirm() }, - title = { Text("Clear history?") }, - text = { Text("This will remove all matches. This action cannot be undone.") }, - confirmButton = { - TextButton(onClick = { onClear() }) { Text("Clear") } - }, - dismissButton = { TextButton(onClick = { onClearConfirm() }) { Text("Cancel") } } - ) - } } } +@Composable +private fun GameDuration( + startTime: Long, + color: Color +) { + var duration by remember { mutableStateOf("") } + + LaunchedEffect(startTime) { + while (true) { + val elapsed = System.currentTimeMillis() - startTime + val seconds = (elapsed / 1000).toInt() + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + + duration = when { + minutes > 0 -> "Duration: ${minutes}m ${remainingSeconds}s" + else -> "${remainingSeconds}s" + } + delay(1000) + } + } + + Text( + text = duration, + style = MaterialTheme.typography.bodyMedium, + color = color.copy(alpha = 0.8f) + ) +} + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt index 226c39c..c8d18c3 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Card @@ -24,9 +25,12 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -42,6 +46,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.atridad.magiccounter.ui.state.GameState import com.atridad.magiccounter.ui.state.PlayerState +import com.atridad.magiccounter.ui.theme.CustomIcons @OptIn(ExperimentalFoundationApi::class) @Composable @@ -51,12 +56,16 @@ import com.atridad.magiccounter.ui.state.PlayerState * - state: immutable state used to bootstrap the in-memory counters * - onProgress: called with a snapshot of the latest state after any user interaction * - onWinner: called once when a winner is determined, with (winnerId, finalState) + * - onStop: called when the game is manually stopped + * - onDelete: called when the game is deleted */ fun GameScreen( modifier: Modifier = Modifier, state: GameState, onProgress: ((GameState) -> Unit)? = null, - onWinner: ((Int, GameState) -> Unit)? = null + onWinner: ((Int, GameState) -> Unit)? = null, + onStop: ((GameState) -> Unit)? = null, + onDelete: (() -> Unit)? = null ) { // Local editable state per player val lifeTotals = remember { mutableStateMapOf() } @@ -66,6 +75,9 @@ fun GameScreen( val eliminated = remember { mutableStateMapOf() } // Tracks whether the game has ended. When true, inputs are frozen and the winner is highlighted. val gameLocked = remember { mutableStateOf(false) } + + // State for stop game confirmation + val showStopConfirm = remember { mutableStateOf(false) } // Initialize state.players.forEach { p -> @@ -101,7 +113,45 @@ fun GameScreen( ) Column(modifier = modifier.padding(12.dp)) { - // Lock game when only one active player remains + // Game status and action buttons + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Game status + Text( + text = if (state.stopped) "Game Stopped" else "Game Active", + style = MaterialTheme.typography.titleMedium, + color = if (state.stopped) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + + // Action buttons (stop/delete) + if (state.stopped) { + // Show delete button for stopped games + IconButton( + onClick = { onDelete?.invoke() } + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete game", + tint = MaterialTheme.colorScheme.error + ) + } + } else { + // Show stop button for active games + IconButton( + onClick = { showStopConfirm.value = true } + ) { + Icon( + CustomIcons.Stop(MaterialTheme.colorScheme.error), + contentDescription = "Stop game" + ) + } + } + } + + // Lock game when only one active player remains or game is stopped val aliveCount = state.players.count { eliminated[it.id] != true } val currentWinnerId: Int? = if (aliveCount == 1) { state.players.first { eliminated[it.id] != true }.id @@ -111,6 +161,12 @@ fun GameScreen( gameLocked.value = true onWinner?.invoke(currentWinnerId, snapshotState()) } + + // If game is stopped, lock it + if (state.stopped && !gameLocked.value) { + gameLocked.value = true + } + val displayPlayers = state.players.sortedBy { eliminated[it.id] == true } LazyColumn( @@ -123,6 +179,7 @@ fun GameScreen( val perPlayerCommander: Map = commanderDamages[player.id]?.toMap() ?: emptyMap() PlayerCard( player = player, + gameState = state, opponents = state.players.map { it.id }.filter { it != player.id }, life = lifeTotals[player.id] ?: state.startingLife, onLifeChange = { new -> @@ -163,6 +220,27 @@ fun GameScreen( ) } } + + // Stop game confirmation dialog + if (showStopConfirm.value) { + AlertDialog( + onDismissRequest = { showStopConfirm.value = false }, + title = { Text("Stop Game?") }, + text = { Text("Are you sure you want to stop this game? This will end the current session.") }, + confirmButton = { + TextButton( + onClick = { + val stoppedState = snapshotState().copy(stopped = true) + onStop?.invoke(stoppedState) + showStopConfirm.value = false + } + ) { Text("Stop Game") } + }, + dismissButton = { + TextButton(onClick = { showStopConfirm.value = false }) { Text("Cancel") } + } + ) + } } } @@ -194,6 +272,7 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color @Composable private fun PlayerCard( player: PlayerState, + gameState: GameState, opponents: List, life: Int, onLifeChange: (Int) -> Unit, @@ -251,12 +330,22 @@ private fun PlayerCard( if (isEliminated) { // Skull overlay - Text( - text = "☠️", - fontSize = 64.sp, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value) - ) + Column( + modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + CustomIcons.Skull(MaterialTheme.colorScheme.error), + contentDescription = "Player eliminated", + modifier = Modifier.size(48.dp) + ) + Text( + text = "ELIMINATED", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium + ) + } } } } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt index 0a422e7..8d84890 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt @@ -47,7 +47,7 @@ fun SetupScreen( val names = remember { mutableStateListOf() } LaunchedEffect(playerCount) { while (names.size < playerCount) names.add(defaultPlayerName(names.size)) - while (names.size > playerCount) names.removeLast() + while (names.size > playerCount) names.removeAt(names.lastIndex) } Column( diff --git a/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt b/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt index 2d45f29..461a016 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt @@ -20,7 +20,8 @@ data class GameState( val players: List, val startingLife: Int, val trackPoison: Boolean, - val trackCommanderDamage: Boolean + val trackCommanderDamage: Boolean, + val stopped: Boolean = false ) fun defaultPlayerName(index: Int): String = "Player ${index + 1}" diff --git a/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt b/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt new file mode 100644 index 0000000..1cbe97d --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt @@ -0,0 +1,241 @@ +package com.atridad.magiccounter.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +object CustomIcons { + fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Stop", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(6f, 6f) + horizontalLineTo(18f) + verticalLineTo(18f) + horizontalLineTo(6f) + close() + }.build() + + fun Pause(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Pause", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(6f, 19f) + horizontalLineTo(10f) + verticalLineTo(5f) + horizontalLineTo(6f) + close() + moveTo(14f, 5f) + verticalLineTo(19f) + horizontalLineTo(18f) + verticalLineTo(5f) + close() + }.build() + + fun Home(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Home", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(10f, 20f) + verticalLineTo(14f) + horizontalLineTo(14f) + verticalLineTo(20f) + horizontalLineTo(19f) + verticalLineTo(12f) + horizontalLineTo(22f) + lineTo(12f, 3f) + lineTo(2f, 12f) + horizontalLineTo(5f) + verticalLineTo(20f) + close() + }.build() + + fun Trophy(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Trophy", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(7f, 15f) + horizontalLineTo(17f) + verticalLineTo(17f) + horizontalLineTo(5f) + verticalLineTo(15f) + close() + moveTo(7f, 13f) + horizontalLineTo(17f) + verticalLineTo(15f) + horizontalLineTo(7f) + close() + moveTo(7f, 11f) + horizontalLineTo(17f) + verticalLineTo(13f) + horizontalLineTo(7f) + close() + moveTo(7f, 9f) + horizontalLineTo(17f) + verticalLineTo(11f) + horizontalLineTo(7f) + close() + moveTo(7f, 7f) + horizontalLineTo(17f) + verticalLineTo(9f) + horizontalLineTo(7f) + close() + moveTo(7f, 5f) + horizontalLineTo(17f) + verticalLineTo(7f) + horizontalLineTo(7f) + close() + moveTo(7f, 3f) + horizontalLineTo(17f) + verticalLineTo(5f) + horizontalLineTo(7f) + close() + moveTo(7f, 1f) + horizontalLineTo(17f) + verticalLineTo(3f) + horizontalLineTo(7f) + close() + }.build() + + fun Skull(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Skull", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(12f, 2f) + curveTo(13.1f, 2f, 14f, 2.9f, 14f, 4f) + curveTo(14f, 5.1f, 13.1f, 6f, 12f, 6f) + curveTo(10.9f, 6f, 10f, 5.1f, 10f, 4f) + curveTo(10f, 2.9f, 10.9f, 2f, 12f, 2f) + close() + moveTo(21f, 9f) + verticalLineTo(7f) + lineTo(15f, 7f) + lineTo(15f, 5f) + curveTo(15f, 3.9f, 14.1f, 3f, 13f, 3f) + lineTo(11f, 3f) + curveTo(9.9f, 3f, 9f, 3.9f, 9f, 5f) + lineTo(9f, 7f) + lineTo(3f, 7f) + verticalLineTo(9f) + lineTo(5f, 9f) + verticalLineTo(20f) + curveTo(5f, 21.1f, 5.9f, 22f, 7f, 22f) + lineTo(17f, 22f) + curveTo(18.1f, 22f, 19f, 21.1f, 19f, 20f) + lineTo(19f, 9f) + lineTo(21f, 9f) + close() + moveTo(12f, 18f) + curveTo(10.9f, 18f, 10f, 17.1f, 10f, 16f) + curveTo(10f, 14.9f, 10.9f, 14f, 12f, 14f) + curveTo(13.1f, 14f, 14f, 14.9f, 14f, 16f) + curveTo(14f, 17.1f, 13.1f, 18f, 12f, 18f) + close() + }.build() + + fun Poison(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Poison", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(12f, 2f) + curveTo(13.1f, 2f, 14f, 2.9f, 14f, 4f) + curveTo(14f, 5.1f, 13.1f, 6f, 12f, 6f) + curveTo(10.9f, 6f, 10f, 5.1f, 10f, 4f) + curveTo(10f, 2.9f, 10.9f, 2f, 12f, 2f) + close() + moveTo(21f, 9f) + verticalLineTo(7f) + lineTo(15f, 7f) + lineTo(15f, 5f) + curveTo(15f, 3.9f, 14.1f, 3f, 13f, 3f) + lineTo(11f, 3f) + curveTo(9.9f, 3f, 9f, 3.9f, 9f, 5f) + lineTo(9f, 7f) + lineTo(3f, 7f) + verticalLineTo(9f) + lineTo(5f, 9f) + verticalLineTo(20f) + curveTo(5f, 21.1f, 5.9f, 22f, 7f, 22f) + lineTo(17f, 22f) + curveTo(18.1f, 22f, 19f, 21.1f, 19f, 20f) + lineTo(19f, 9f) + lineTo(21f, 9f) + close() + moveTo(12f, 18f) + curveTo(10.9f, 18f, 10f, 17.1f, 10f, 16f) + curveTo(10f, 14.9f, 10.9f, 14f, 12f, 14f) + curveTo(13.1f, 14f, 14f, 14.9f, 14f, 16f) + curveTo(14f, 17.1f, 13.1f, 14f, 12f, 18f) + close() + }.build() + + fun Sword(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Sword", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(6.92f, 5f) + horizontalLineTo(5.14f) + lineTo(4.5f, 5.64f) + lineTo(6.92f, 8.07f) + lineTo(6.92f, 5f) + close() + moveTo(19.5f, 8.5f) + lineTo(18.79f, 9.21f) + lineTo(12.71f, 15.29f) + lineTo(13.41f, 16f) + horizontalLineTo(17f) + verticalLineTo(22f) + horizontalLineTo(19f) + verticalLineTo(16f) + horizontalLineTo(22.59f) + lineTo(23.29f, 15.29f) + lineTo(17.21f, 9.21f) + lineTo(16.5f, 8.5f) + lineTo(19.5f, 8.5f) + close() + moveTo(6.92f, 19f) + verticalLineTo(16.93f) + lineTo(4.5f, 19.36f) + lineTo(5.14f, 20f) + horizontalLineTo(6.92f) + verticalLineTo(19f) + close() + }.build() +}