diff --git a/android/.idea/.name b/android/.idea/.name new file mode 100644 index 0000000..7d53adb --- /dev/null +++ b/android/.idea/.name @@ -0,0 +1 @@ +MagicCounter \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml index 3b0be22..6c5519f 100644 --- a/android/.idea/misc.xml +++ b/android/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml index 35eb1dd..62bd7a0 100644 --- a/android/.idea/vcs.xml +++ b/android/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e45a04a..d8ac81b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.atridad.magiccounter" minSdk = 31 targetSdk = 36 - versionCode = 3 - versionName = "1.3.0" + versionCode = 4 + versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt b/android/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt index 75c0f99..258289b 100644 --- a/android/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt +++ b/android/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt @@ -65,7 +65,7 @@ fun MagicCounterApp() { val settingsVm: AppSettingsViewModel = viewModel() val theme = settingsVm.themeMode.collectAsState() val historyState = settingsVm.matchHistory.collectAsState() - + MagicCounterTheme(themeMode = theme.value) { @@ -81,7 +81,7 @@ fun MagicCounterApp() { } }, actions = { - + IconButton(onClick = { screenStack.add(Screen.Settings) }) { Icon(Icons.Default.Settings, contentDescription = "App settings") } @@ -142,7 +142,7 @@ fun MagicCounterApp() { screenStack.removeAt(screenStack.lastIndex) return@SetupScreen } - + // Create and persist a new MatchRecord val newId = java.util.UUID.randomUUID().toString() val now = System.currentTimeMillis() @@ -175,6 +175,8 @@ fun MagicCounterApp() { .padding(paddingValues) .fillMaxSize(), state = stateForGame, + matchName = record?.name ?: "Game", + startedAtEpochMs = record?.startedAtEpochMs ?: System.currentTimeMillis(), onProgress = { updated -> val current = historyState.value val idx = current.indexOfFirst { it.id == id } @@ -233,7 +235,7 @@ fun MagicCounterApp() { onSelect = { settingsVm.setTheme(it) } ) } - + } } @@ -242,7 +244,7 @@ fun MagicCounterApp() { @Composable private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) { Column( - modifier = modifier.padding(24.dp), + modifier = modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( @@ -250,7 +252,7 @@ private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (T style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary ) - + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( "Theme", @@ -291,17 +293,17 @@ private fun HomeScreen( contentAlignment = Alignment.Center ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { Icon( - Icons.Default.History, + Icons.Default.History, contentDescription = "Empty history", modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - "No games yet", + "No games yet", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -332,13 +334,13 @@ private fun HomeScreen( onClick = { onResume(activeGame) } ) { Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( - "Active Game", + "Active Game", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimary ) @@ -382,23 +384,29 @@ private fun HomeScreen( onClick = { onResume(rec) } ) { Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( - rec.name, + 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 + val winnerId = rec.winnerPlayerId ?: rec.state.winnerPlayerId + val winnerName = winnerId?.let { wId -> + rec.state.players.find { it.id == wId }?.name + } + val statusText = when { + winnerName != null -> "Winner: $winnerName" + rec.state.stopped -> "Stopped" + else -> "Finished" + } Text( statusText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + color = if (winnerName != null) Color(0xFF2E7D32) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -409,12 +417,12 @@ private fun HomeScreen( containerColor = MaterialTheme.colorScheme.secondary, contentColor = MaterialTheme.colorScheme.onSecondary ) - ) { + ) { Icon( - Icons.Default.Visibility, + Icons.Default.Visibility, contentDescription = "View match", modifier = Modifier.size(20.dp) - ) + ) } androidx.compose.material3.FilledTonalIconButton( onClick = { pendingDeleteId = rec.id }, @@ -423,12 +431,12 @@ private fun HomeScreen( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError ) - ) { + ) { Icon( - Icons.Default.Delete, + Icons.Default.Delete, contentDescription = "Delete", modifier = Modifier.size(20.dp) - ) + ) } } } @@ -461,14 +469,14 @@ private fun GameDuration( 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" @@ -476,12 +484,10 @@ private fun GameDuration( delay(1000) } } - + Text( text = duration, style = MaterialTheme.typography.bodyMedium, color = color.copy(alpha = 0.8f) ) } - - diff --git a/android/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt b/android/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt index 2f7dc50..8b787cf 100644 --- a/android/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt +++ b/android/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt @@ -1,22 +1,36 @@ package com.atridad.magiccounter.ui.screens +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.PointerInputChange @@ -24,50 +38,67 @@ import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.zIndex import java.util.Collections import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay 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.material.icons.filled.Shield import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.AlertDialog +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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 +import androidx.compose.ui.graphics.luminance +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable /** * Game screen hosting all player panels. @@ -77,41 +108,60 @@ import com.atridad.magiccounter.ui.theme.CustomIcons * - 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 + * - matchName: name of the match to display in header + * - startedAtEpochMs: timestamp when the match started */ fun GameScreen( modifier: Modifier = Modifier, state: GameState, + matchName: String = "Game", + startedAtEpochMs: Long = System.currentTimeMillis(), onProgress: ((GameState) -> Unit)? = null, onWinner: ((Int, GameState) -> Unit)? = null, onStop: ((GameState) -> Unit)? = null, onDelete: (() -> Unit)? = null ) { - // Local editable state per player val lifeTotals = remember { mutableStateMapOf() } val poisonTotals = remember { mutableStateMapOf() } - + val commanderDamages = remember { mutableStateMapOf>() } 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) } - - // Local list for drag-and-drop reordering + var commanderSheetPlayer by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState() + var elapsedSeconds by remember { mutableLongStateOf(0L) } val uiPlayers = remember { mutableStateListOf() } - + LaunchedEffect(state.players) { uiPlayers.clear() uiPlayers.addAll(state.players) } - // Initialize + LaunchedEffect(state.stopped, gameLocked.value) { + if (!state.stopped && !gameLocked.value) { + while (true) { + elapsedSeconds = (System.currentTimeMillis() - startedAtEpochMs) / 1000 + delay(1000L) + } + } + } + state.players.forEach { p -> lifeTotals.putIfAbsent(p.id, p.life) poisonTotals.putIfAbsent(p.id, p.poison) commanderDamages.putIfAbsent(p.id, p.commanderDamages.toMutableMap()) - eliminated.putIfAbsent(p.id, p.scooped) + + val isEliminated = p.scooped || + p.life <= 0 || + p.poison >= 10 || + p.commanderDamages.values.any { it >= 21 } + eliminated.putIfAbsent(p.id, isEliminated) + } + + val savedWinnerId = state.winnerPlayerId + if (savedWinnerId != null && !gameLocked.value) { + gameLocked.value = true } fun checkElimination(playerId: Int) { @@ -120,13 +170,18 @@ fun GameScreen( eliminated[playerId] = true return } + val poison = poisonTotals[playerId] ?: 0 + if (poison >= 10) { + eliminated[playerId] = true + return + } val fromMap: Map = commanderDamages[playerId] ?: emptyMap() if (fromMap.values.any { it >= 21 }) { eliminated[playerId] = true } } - fun snapshotState(): GameState = state.copy( + fun snapshotState(winnerId: Int? = null): GameState = state.copy( players = uiPlayers.map { p -> PlayerState( id = p.id, @@ -136,29 +191,83 @@ fun GameScreen( commanderDamages = commanderDamages[p.id]?.toMap() ?: p.commanderDamages, scooped = eliminated[p.id] == true ) - } + }, + stopped = winnerId != null || state.stopped, + winnerPlayerId = winnerId ?: state.winnerPlayerId ) - Column(modifier = modifier.padding(12.dp)) { - // Game status and action buttons + fun formatTime(totalSeconds: Long): String { + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + String.format("%02d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%02d:%02d", minutes, seconds) + } + } + + val aliveCount = state.players.count { eliminated[it.id] != true } + val currentWinnerId: Int? = state.winnerPlayerId ?: if (aliveCount == 1) { + state.players.firstOrNull { eliminated[it.id] != true }?.id + } else null + + val winnerName = currentWinnerId?.let { id -> + state.players.find { it.id == id }?.name + } + + if (currentWinnerId != null && !gameLocked.value && state.winnerPlayerId == null) { + gameLocked.value = true + onWinner?.invoke(currentWinnerId, snapshotState(winnerId = currentWinnerId)) + } + + if ((state.stopped || state.winnerPlayerId != null) && !gameLocked.value) { + gameLocked.value = true + } + + Column(modifier = modifier) { Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.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 + text = matchName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) ) - - // Action buttons (stop/delete) + + Surface( + color = when { + gameLocked.value && currentWinnerId != null -> Color(0xFF2E7D32).copy(alpha = 0.2f) + state.stopped -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.primaryContainer + }, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = when { + state.stopped -> "Stopped" + else -> formatTime(elapsedSeconds) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = when { + gameLocked.value && currentWinnerId != null -> Color(0xFF2E7D32) + state.stopped -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onPrimaryContainer + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + if (state.stopped) { - // Show delete button for stopped games - IconButton( - onClick = { onDelete?.invoke() } - ) { + IconButton(onClick = { onDelete?.invoke() }) { Icon( Icons.Default.Delete, contentDescription = "Delete game", @@ -166,10 +275,7 @@ fun GameScreen( ) } } else { - // Show stop button for active games - IconButton( - onClick = { showStopConfirm.value = true } - ) { + IconButton(onClick = { showStopConfirm.value = true }) { Icon( CustomIcons.Stop(MaterialTheme.colorScheme.error), contentDescription = "Stop game" @@ -178,22 +284,38 @@ fun GameScreen( } } - // 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 - } else null + if (currentWinnerId != null && winnerName != null) { + Surface( + color = Color(0xFF2E7D32).copy(alpha = 0.15f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Flag, + contentDescription = "Winner", + tint = Color(0xFF2E7D32), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "$winnerName Wins!", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color(0xFF2E7D32) + ) + } + } + } - if (currentWinnerId != null && !gameLocked.value) { - gameLocked.value = true - onWinner?.invoke(currentWinnerId, snapshotState()) - } - - // If game is stopped, lock it - if (state.stopped && !gameLocked.value) { - gameLocked.value = true - } - val listState = rememberLazyListState() var draggingItemIndex by remember { mutableStateOf(null) } var draggingItemOffset by remember { mutableFloatStateOf(0f) } @@ -202,16 +324,16 @@ fun GameScreen( LazyColumn( state = listState, modifier = Modifier.weight(1f), - contentPadding = PaddingValues(4.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(uiPlayers, key = { _, item -> item.id }) { index, player -> val accent = seatAccentColor(index, MaterialTheme.colorScheme) val perPlayerCommander: Map = commanderDamages[player.id]?.toMap() ?: emptyMap() - + val isDragging = index == draggingItemIndex val currentItemIndex by rememberUpdatedState(index) - + Box( modifier = Modifier .then(if (isDragging) Modifier else Modifier.animateItem()) @@ -224,25 +346,25 @@ fun GameScreen( } .pointerInput(player.id) { detectDragGesturesAfterShortPress( - onDragStart = { + onDragStart = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) - draggingItemIndex = currentItemIndex + draggingItemIndex = currentItemIndex draggingItemOffset = 0f }, onDrag = { change, dragAmount -> change.consume() draggingItemOffset += dragAmount.y - + val currentIndex = currentItemIndex val currentInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == currentIndex } if (currentInfo != null) { val currentCenter = currentInfo.offset + currentInfo.size / 2 + draggingItemOffset - val targetItem = listState.layoutInfo.visibleItemsInfo.find { - it.index != currentIndex && - currentCenter > it.offset && + val targetItem = listState.layoutInfo.visibleItemsInfo.find { + it.index != currentIndex && + currentCenter > it.offset && currentCenter < (it.offset + it.size) } - + if (targetItem != null) { val targetIndex = targetItem.index if (currentIndex < uiPlayers.size && targetIndex < uiPlayers.size) { @@ -262,50 +384,43 @@ fun GameScreen( } ) { PlayerCard( - player = player, - opponents = state.players.map { it.id }.filter { it != player.id }, - life = lifeTotals[player.id] ?: state.startingLife, - onLifeChange = { new -> - if (gameLocked.value) return@PlayerCard - lifeTotals[player.id] = new - checkElimination(player.id) - onProgress?.invoke(snapshotState()) - }, - poison = poisonTotals[player.id] ?: 0, - onPoisonChange = { - if (!gameLocked.value) { - poisonTotals[player.id] = it - onProgress?.invoke(snapshotState()) - } - }, - trackPoison = state.trackPoison, - trackCommanderDamage = state.trackCommanderDamage, - commanderDamages = perPlayerCommander, - onCommanderDamageChange = { fromId, dmg -> - if (!gameLocked.value) { - val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap() - newMap[fromId] = dmg - commanderDamages[player.id] = newMap + player = player, + allPlayers = state.players, + life = lifeTotals[player.id] ?: state.startingLife, + onLifeChange = { new -> + if (gameLocked.value) return@PlayerCard + lifeTotals[player.id] = new checkElimination(player.id) onProgress?.invoke(snapshotState()) + }, + poison = poisonTotals[player.id] ?: 0, + onPoisonChange = { + if (!gameLocked.value) { + poisonTotals[player.id] = it + checkElimination(player.id) + onProgress?.invoke(snapshotState()) + } + }, + trackPoison = state.trackPoison, + trackCommanderDamage = state.trackCommanderDamage, + commanderDamages = perPlayerCommander, + onCommanderTap = { + commanderSheetPlayer = player + }, + accentColor = accent, + isEliminated = eliminated[player.id] == true, + isWinner = currentWinnerId == player.id, + onScoop = { + if (!gameLocked.value) { + eliminated[player.id] = true + onProgress?.invoke(snapshotState()) + } } - }, - rotation = 0f, - accentColor = accent, - isEliminated = eliminated[player.id] == true, - isWinner = currentWinnerId == player.id, - onScoop = { - if (!gameLocked.value) { - eliminated[player.id] = true - onProgress?.invoke(snapshotState()) - } - } - ) + ) } } } - - // Stop game confirmation dialog + if (showStopConfirm.value) { AlertDialog( onDismissRequest = { showStopConfirm.value = false }, @@ -320,11 +435,42 @@ fun GameScreen( } ) { Text("Stop Game") } }, - dismissButton = { - TextButton(onClick = { showStopConfirm.value = false }) { Text("Cancel") } + dismissButton = { + TextButton(onClick = { showStopConfirm.value = false }) { Text("Cancel") } } ) } + + if (commanderSheetPlayer != null) { + val targetPlayer = commanderSheetPlayer!! + ModalBottomSheet( + onDismissRequest = { commanderSheetPlayer = null }, + sheetState = sheetState + ) { + CommanderDamageSheet( + targetPlayer = targetPlayer, + allPlayers = state.players, + commanderDamages = commanderDamages[targetPlayer.id]?.toMap() ?: emptyMap(), + onDamageChange = { fromId, newDamage -> + if (!gameLocked.value) { + val oldDamage = commanderDamages[targetPlayer.id]?.get(fromId) ?: 0 + val damageDelta = newDamage - oldDamage + + val newMap = (commanderDamages[targetPlayer.id] ?: mutableMapOf()).toMutableMap() + newMap[fromId] = newDamage.coerceAtLeast(0) + commanderDamages[targetPlayer.id] = newMap + + val currentLife = lifeTotals[targetPlayer.id] ?: state.startingLife + lifeTotals[targetPlayer.id] = currentLife - damageDelta + + checkElimination(targetPlayer.id) + onProgress?.invoke(snapshotState()) + } + }, + onDismiss = { commanderSheetPlayer = null } + ) + } + } } } @@ -337,26 +483,262 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color else -> scheme.tertiaryContainer } +/** + * Tracks delta changes for displaying temporary indicators. + */ +class DeltaTrackerState { + var delta by mutableIntStateOf(0) + var resetJob: Job? = null + + fun addDelta(amount: Int, scope: kotlinx.coroutines.CoroutineScope) { + delta += amount + resetJob?.cancel() + resetJob = scope.launch { + delay(2000L) + delta = 0 + } + } + + fun reset() { + resetJob?.cancel() + delta = 0 + } +} + +@Composable +fun rememberDeltaTracker(): DeltaTrackerState { + return remember { DeltaTrackerState() } +} + +/** + * Delta indicator that shows accumulated change amount. + * Uses absolute positioning so it doesn't affect layout. + */ +@Composable +private fun DeltaIndicator( + delta: Int, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + AnimatedVisibility( + visible = delta != 0, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + Text( + text = if (delta > 0) "+$delta" else "$delta", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (delta > 0) Color(0xFF4CAF50) else Color(0xFFF44336), + modifier = Modifier.alpha(0.85f) + ) + } + } +} + +/** + * Custom delta input dialog. + */ +@Composable +private fun CustomDeltaDialog( + isIncreasing: Boolean, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + var inputText by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Enter Amount") }, + text = { + Column { + Text( + text = if (isIncreasing) "Enter the amount to add" else "Enter the amount to subtract", + style = MaterialTheme.typography.bodyMedium + ) + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it.filter { c -> c.isDigit() } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.padding(top = 8.dp) + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val amount = inputText.toIntOrNull() + if (amount != null && amount > 0) { + onConfirm(if (isIncreasing) amount else -amount) + } + onDismiss() + } + ) { + Text(if (isIncreasing) "Add" else "Subtract") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +/** + * Commander damage bottom sheet content + */ +@Composable +private fun CommanderDamageSheet( + targetPlayer: PlayerState, + allPlayers: List, + commanderDamages: Map, + onDamageChange: (fromId: Int, newDamage: Int) -> Unit, + onDismiss: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Commander Damage to ${targetPlayer.name}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + allPlayers.filter { it.id != targetPlayer.id }.forEach { attacker -> + val damage = commanderDamages[attacker.id] ?: 0 + CommanderDamageRow( + attackerName = attacker.name, + damage = damage, + onDamageChange = { newDamage -> onDamageChange(attacker.id, newDamage) } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End) + ) { + Text("Done") + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +/** + * Single row in commander damage sheet + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CommanderDamageRow( + attackerName: String, + damage: Int, + onDamageChange: (Int) -> Unit +) { + val deltaTracker = rememberDeltaTracker() + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + var showCustomDeltaDialog by remember { mutableStateOf(false) } + var isIncreasing by remember { mutableStateOf(true) } + + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val redColor = if (isDarkTheme) Color(0xFFEF9A9A) else Color(0xFFC62828) + val greenColor = if (isDarkTheme) Color(0xFFA5D6A7) else Color(0xFF2E7D32) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = attackerName, + style = MaterialTheme.typography.titleMedium + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(contentAlignment = Alignment.Center) { + CounterIconButtonWithLongPress( + icon = Icons.Default.Remove, + contentDescription = "decrease damage", + onClick = { + val newDamage = (damage - 1).coerceAtLeast(0) + onDamageChange(newDamage) + deltaTracker.addDelta(-1, scope) + }, + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isIncreasing = false + showCustomDeltaDialog = true + }, + buttonColor = redColor + ) + if (deltaTracker.delta < 0) { + DeltaIndicator( + delta = deltaTracker.delta, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-18).dp) + ) + } + } + Text( + text = damage.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp), + textAlign = TextAlign.Center + ) + Box(contentAlignment = Alignment.Center) { + CounterIconButtonWithLongPress( + icon = Icons.Default.Add, + contentDescription = "increase damage", + onClick = { + onDamageChange(damage + 1) + deltaTracker.addDelta(1, scope) + }, + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isIncreasing = true + showCustomDeltaDialog = true + }, + buttonColor = greenColor + ) + if (deltaTracker.delta > 0) { + DeltaIndicator( + delta = deltaTracker.delta, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-18).dp) + ) + } + } + } + } + + if (showCustomDeltaDialog) { + CustomDeltaDialog( + isIncreasing = isIncreasing, + onDismiss = { showCustomDeltaDialog = false }, + onConfirm = { delta -> + val newDamage = (damage + delta).coerceAtLeast(0) + onDamageChange(newDamage) + deltaTracker.addDelta(delta, scope) + } + ) + } +} + /** * Player panel with counters and actions. - * - * - player: immutable player data snapshot. - * - opponents: list of opponent player ids for commander damage rows. - * - life/poison: current counter values for this player. - * - onLifeChange/onPoisonChange: invoked with the new value when a counter is adjusted. - * - trackPoison/trackCommanderDamage: toggles for which rows are shown. - * - commanderDamages: map of damage received from opponent id -> damage. - * - onCommanderDamageChange: callback with (fromId, newDamage). - * - rotation: visual rotation of the card in degrees. - * - accentColor: seat accent color used for text and default border. - * - isEliminated: when true, card is dimmed and inputs disabled. - * - isWinner: when true, card is highlighted in green and inputs disabled. - * - onScoop: invoked when the player taps Scoop. */ @Composable private fun PlayerCard( player: PlayerState, - opponents: List, + allPlayers: List, life: Int, onLifeChange: (Int) -> Unit, poison: Int, @@ -364,8 +746,7 @@ private fun PlayerCard( trackPoison: Boolean, trackCommanderDamage: Boolean, commanderDamages: Map, - onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit, - rotation: Float, + onCommanderTap: () -> Unit, accentColor: Color, isEliminated: Boolean, isWinner: Boolean, @@ -379,16 +760,28 @@ private fun PlayerCard( Card( modifier = Modifier .padding(4.dp) - .fillMaxWidth() - .graphicsLayer { rotationZ = rotation }, + .fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), colors = CardDefaults.cardColors(containerColor = containerColor.value), border = BorderStroke(3.dp, if (isWinner) Color(0xFF2E7D32) else accentColor) ) { Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.padding(12.dp).alpha(if (isEliminated) 0.45f else 1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(player.name, style = MaterialTheme.typography.titleMedium, color = if (isWinner) Color(0xFF2E7D32) else accentColor) + Column( + modifier = Modifier + .padding(12.dp) + .alpha(if (isEliminated) 0.45f else 1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + player.name, + style = MaterialTheme.typography.headlineSmall, + color = if (isWinner) Color(0xFF2E7D32) else accentColor + ) TextButton(onClick = onScoop, enabled = controlsEnabled) { Icon(Icons.Default.Flag, contentDescription = "Scoop") Text("Scoop") @@ -397,24 +790,38 @@ private fun PlayerCard( BigLifeRow(value = life, onChange = onLifeChange, enabled = controlsEnabled) - if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = controlsEnabled) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (trackPoison) { + PoisonCounter( + value = poison, + onChange = onPoisonChange, + enabled = controlsEnabled + ) + } - if (trackCommanderDamage) { - HorizontalDivider() - Text("Commander damage", style = MaterialTheme.typography.titleSmall) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - opponents.forEach { fromId -> - val value = commanderDamages[fromId] ?: 0 - ChipRow(label = "From P${fromId + 1}", value = value, onChange = { onCommanderDamageChange(fromId, it) }, enabled = controlsEnabled) - } + if (trackPoison && trackCommanderDamage) { + Spacer(modifier = Modifier.width(16.dp)) + } + + if (trackCommanderDamage) { + CommanderDamageButton( + totalDamage = commanderDamages.values.sum(), + onClick = onCommanderTap, + enabled = controlsEnabled + ) } } } if (isEliminated) { - // Skull overlay Column( - modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value), + modifier = Modifier + .align(Alignment.Center) + .alpha(overlayAlpha.value), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( @@ -435,73 +842,323 @@ private fun PlayerCard( } /** - * Counter row used for poison and commander damage. - * - * - label: row label text - * - value: current integer value - * - onChange: callback with the new value after +/- is pressed - * - enabled: when false, the +/- buttons are disabled + * Styled poison counter with icon like iOS */ +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ChipRow( - label: String, +private fun PoisonCounter( value: Int, onChange: (Int) -> Unit, - enabled: Boolean = true + enabled: Boolean ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + val deltaTracker = rememberDeltaTracker() + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + var showCustomDeltaDialog by remember { mutableStateOf(false) } + var isIncreasing by remember { mutableStateOf(true) } + + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val purpleColor = if (isDarkTheme) Color(0xFFCE93D8) else Color(0xFF7B1FA2) + val purpleBgAlpha = if (isDarkTheme) 0.25f else 0.12f + + Surface( + color = purpleColor.copy(alpha = purpleBgAlpha), + shape = RoundedCornerShape(16.dp) ) { - Text(label) - Row(verticalAlignment = Alignment.CenterVertically) { - CounterIconButton(Icons.Default.Remove, "decrement", enabled = enabled) { onChange(value - 1) } - Text( - text = value.toString(), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 12.dp), - textAlign = TextAlign.Center + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box(contentAlignment = Alignment.Center) { + SmallCounterButton( + icon = Icons.Default.Remove, + contentDescription = "decrease poison", + enabled = enabled, + onClick = { + onChange((value - 1).coerceAtLeast(0)) + deltaTracker.addDelta(-1, scope) + }, + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isIncreasing = false + showCustomDeltaDialog = true + } + ) + if (deltaTracker.delta < 0) { + DeltaIndicator( + delta = deltaTracker.delta, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-16).dp) + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Icon( + CustomIcons.Droplet(purpleColor), + contentDescription = "Poison", + modifier = Modifier.size(16.dp), + tint = purpleColor + ) + Text( + text = value.toString(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = purpleColor + ) + } + + Box(contentAlignment = Alignment.Center) { + SmallCounterButton( + icon = Icons.Default.Add, + contentDescription = "increase poison", + enabled = enabled, + onClick = { + onChange(value + 1) + deltaTracker.addDelta(1, scope) + }, + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isIncreasing = true + showCustomDeltaDialog = true + } + ) + if (deltaTracker.delta > 0) { + DeltaIndicator( + delta = deltaTracker.delta, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-16).dp) + ) + } + } + } + } + + if (showCustomDeltaDialog) { + CustomDeltaDialog( + isIncreasing = isIncreasing, + onDismiss = { showCustomDeltaDialog = false }, + onConfirm = { delta -> + onChange((value + delta).coerceAtLeast(0)) + deltaTracker.addDelta(delta, scope) + } + ) + } +} + +/** + * Commander damage button that opens the sheet + */ +@Composable +private fun CommanderDamageButton( + totalDamage: Int, + onClick: () -> Unit, + enabled: Boolean +) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val orangeColor = if (isDarkTheme) Color(0xFFFFB74D) else Color(0xFFE65100) + val orangeBgAlpha = if (isDarkTheme) 0.25f else 0.12f + + Surface( + onClick = onClick, + enabled = enabled, + color = orangeColor.copy(alpha = orangeBgAlpha), + shape = CircleShape + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Shield, + contentDescription = "Commander damage", + tint = orangeColor, + modifier = Modifier.size(20.dp) + ) + Text( + text = "CMD", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = orangeColor + ) + } + } +} + +/** + * Small counter button for poison + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SmallCounterButton( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val buttonBgColor = if (isDarkTheme) + MaterialTheme.colorScheme.surfaceVariant + else + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + + Surface( + modifier = Modifier + .size(28.dp) + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongPress + ), + shape = CircleShape, + color = buttonBgColor.copy(alpha = if (enabled) 0.8f else 0.38f), + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.7f else 0.38f) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(16.dp) ) - CounterIconButton(Icons.Default.Add, "increment", enabled = enabled) { onChange(value + 1) } } } } /** * Life total row with number and +/- buttons. - * - * - value: current life total - * - onChange: callback with new life total - * - enabled: when false, buttons are disabled */ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = true) { + val deltaTracker = rememberDeltaTracker() + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + var showCustomDeltaDialog by remember { mutableStateOf(false) } + var isIncreasing by remember { mutableStateOf(true) } + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.Center ) { - Text("Life", style = MaterialTheme.typography.titleMedium) - Row(verticalAlignment = Alignment.CenterVertically) { - CounterIconButton(Icons.Default.Remove, "decrement life", enabled = enabled) { onChange(value - 1) } - Text( - text = value.toString(), - style = MaterialTheme.typography.displaySmall, - modifier = Modifier.padding(horizontal = 16.dp), - textAlign = TextAlign.Center + Box(contentAlignment = Alignment.Center) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val redColor = if (isDarkTheme) Color(0xFFEF9A9A) else Color(0xFFC62828) + CounterIconButtonWithLongPress( + icon = Icons.Default.Remove, + contentDescription = "decrement life", + enabled = enabled, + onClick = { + onChange(value - 1) + deltaTracker.addDelta(-1, scope) + }, + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isIncreasing = false + showCustomDeltaDialog = true + }, + buttonColor = redColor ) - CounterIconButton(Icons.Default.Add, "increment life", enabled = enabled) { onChange(value + 1) } + if (deltaTracker.delta < 0) { + DeltaIndicator( + delta = deltaTracker.delta, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-20).dp) + ) + } + } + Text( + text = value.toString(), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp), + textAlign = TextAlign.Center + ) + Box(contentAlignment = Alignment.Center) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val greenColor = if (isDarkTheme) Color(0xFFA5D6A7) else Color(0xFF2E7D32) + CounterIconButtonWithLongPress( + icon = Icons.Default.Add, + contentDescription = "increment life", + enabled = enabled, + onClick = { + onChange(value + 1) + deltaTracker.addDelta(1, scope) + }, + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isIncreasing = true + showCustomDeltaDialog = true + }, + buttonColor = greenColor + ) + if (deltaTracker.delta > 0) { + DeltaIndicator( + delta = deltaTracker.delta, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-20).dp) + ) + } + } + } + + if (showCustomDeltaDialog) { + CustomDeltaDialog( + isIncreasing = isIncreasing, + onDismiss = { showCustomDeltaDialog = false }, + onConfirm = { delta -> + onChange(value + delta) + deltaTracker.addDelta(delta, scope) + } + ) + } +} + +/** + * Icon button with long press support for counters. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CounterIconButtonWithLongPress( + icon: ImageVector, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, + onLongPress: () -> Unit, + buttonColor: Color = MaterialTheme.colorScheme.secondaryContainer +) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val bgAlpha = if (isDarkTheme) 0.35f else 0.2f + + Surface( + modifier = Modifier + .size(48.dp) + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongPress + ), + shape = CircleShape, + color = buttonColor.copy(alpha = if (enabled) bgAlpha else bgAlpha * 0.5f), + contentColor = buttonColor.copy(alpha = if (enabled) 1f else 0.38f) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon(icon, contentDescription = contentDescription) } } } /** - * Icon button used for counters. - * - icon: vector asset to display - * - contentDescription: a11y description - * - enabled: controls click availability - * - onClick: invoked on press + * Icon button used for counters (legacy, kept for compatibility). */ @Composable private fun CounterIconButton(icon: ImageVector, contentDescription: String, enabled: Boolean = true, onClick: () -> Unit) { @@ -528,11 +1185,11 @@ suspend fun PointerInputScope.detectDragGesturesAfterShortPress( while (true) { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == downId } - + if (change == null || !change.pressed) { throw CancellationException("Up") } - + val positionChange = change.position - down.position if (positionChange.getDistance() > viewConfiguration.touchSlop) { throw CancellationException("Moved") @@ -547,18 +1204,18 @@ suspend fun PointerInputScope.detectDragGesturesAfterShortPress( if (dragStarted) { onDragStart(down.position) - + try { awaitPointerEventScope { val downId = down.id while (true) { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == downId } - + if (change == null || !change.pressed) { break } - + val delta = change.positionChange() if (delta != Offset.Zero) { change.consume() @@ -572,5 +1229,3 @@ suspend fun PointerInputScope.detectDragGesturesAfterShortPress( } } } - - diff --git a/android/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt b/android/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt index 461a016..c8ce6b7 100644 --- a/android/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt +++ b/android/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt @@ -21,9 +21,8 @@ data class GameState( val startingLife: Int, val trackPoison: Boolean, val trackCommanderDamage: Boolean, - val stopped: Boolean = false + val stopped: Boolean = false, + val winnerPlayerId: Int? = null ) fun defaultPlayerName(index: Int): String = "Player ${index + 1}" - - diff --git a/android/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt b/android/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt index 1cbe97d..a0f8295 100644 --- a/android/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt +++ b/android/app/src/main/java/com/atridad/magiccounter/ui/theme/CustomIcons.kt @@ -201,6 +201,25 @@ object CustomIcons { close() }.build() + fun Droplet(color: Color = Color.Black): ImageVector = ImageVector.Builder( + name = "Droplet", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).path( + fill = SolidColor(color) + ) { + moveTo(12f, 2.69f) + lineTo(17.66f, 8.35f) + curveTo(19.1f, 9.79f, 20f, 11.79f, 20f, 14f) + curveTo(20f, 18.42f, 16.42f, 22f, 12f, 22f) + curveTo(7.58f, 22f, 4f, 18.42f, 4f, 14f) + curveTo(4f, 11.79f, 4.9f, 9.79f, 6.34f, 8.35f) + lineTo(12f, 2.69f) + close() + }.build() + fun Sword(color: Color = Color.Black): ImageVector = ImageVector.Builder( name = "Sword", defaultWidth = 24.dp, diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 96f1d6d..aa7104c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,23 +1,23 @@ [versions] agp = "8.12.3" -kotlin = "2.0.21" -coreKtx = "1.10.1" +kotlin = "2.3.0" +coreKtx = "1.17.0" junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" -appcompat = "1.6.1" -material = "1.10.0" -constraintlayout = "2.1.4" -lifecycleLivedataKtx = "2.6.1" -lifecycleViewmodelKtx = "2.6.1" -navigationFragmentKtx = "2.6.0" -navigationUiKtx = "2.6.0" -composeBom = "2024.10.01" -activityCompose = "1.9.2" -lifecycleRuntimeCompose = "2.8.6" -lifecycleViewmodelCompose = "2.8.6" -datastore = "1.1.1" -serialization = "1.7.3" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.13.0" +constraintlayout = "2.2.1" +lifecycleLivedataKtx = "2.10.0" +lifecycleViewmodelKtx = "2.10.0" +navigationFragmentKtx = "2.9.6" +navigationUiKtx = "2.9.6" +composeBom = "2025.12.01" +activityCompose = "1.12.2" +lifecycleRuntimeCompose = "2.10.0" +lifecycleViewmodelCompose = "2.10.0" +datastore = "1.2.0" +serialization = "1.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } diff --git a/ios/MagicCounter.xcodeproj/project.pbxproj b/ios/MagicCounter.xcodeproj/project.pbxproj index e53cce8..575ce18 100644 --- a/ios/MagicCounter.xcodeproj/project.pbxproj +++ b/ios/MagicCounter.xcodeproj/project.pbxproj @@ -411,7 +411,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -427,7 +427,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.MagicCounter; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -447,7 +447,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -463,7 +463,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.MagicCounter; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 96a97bf..77db9b9 100644 Binary files a/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/MagicCounter/Components.swift b/ios/MagicCounter/Components.swift index c476835..a7b91b6 100644 --- a/ios/MagicCounter/Components.swift +++ b/ios/MagicCounter/Components.swift @@ -6,61 +6,180 @@ // import SwiftUI +import Combine /** * A circular button with an icon, used for game controls. + * Supports optional long press to trigger a custom delta prompt. */ struct CircleButton: View { let icon: String let color: Color let size: CGFloat let action: () -> Void - - init(icon: String, color: Color, size: CGFloat = 44, action: @escaping () -> Void) { + var onLongPress: (() -> Void)? = nil + + init(icon: String, color: Color, size: CGFloat = 44, action: @escaping () -> Void, onLongPress: (() -> Void)? = nil) { self.icon = icon self.color = color self.size = size self.action = action + self.onLongPress = onLongPress } - + var body: some View { - Button(action: action) { - Image(systemName: icon) - .font(size < 40 ? .caption : .title3) - .frame(width: size, height: size) - .background(color.opacity(0.2)) - .clipShape(Circle()) - .foregroundStyle(color) + Image(systemName: icon) + .font(size < 40 ? .caption : .title3) + .frame(width: size, height: size) + .background(color.opacity(0.2)) + .clipShape(Circle()) + .foregroundStyle(color) + .contentShape(Circle()) + .onTapGesture { + action() + } + .onLongPressGesture(minimumDuration: 0.5) { + onLongPress?() + } + } +} + +/** + * A view that shows the accumulated delta change near a counter button. + * Automatically fades out after a delay. + */ +struct DeltaIndicator: View { + let delta: Int + let alignment: HorizontalAlignment + + var body: some View { + if delta != 0 { + Text(delta > 0 ? "+\(delta)" : "\(delta)") + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundStyle(delta > 0 ? .green : .red) + .opacity(0.8) + .transition(.opacity.combined(with: .scale)) } } } +/** + * Observable class to manage delta accumulation and fade-out timing. + */ +class DeltaTracker: ObservableObject { + @Published var delta: Int = 0 + private var resetTimer: Timer? + + func addDelta(_ amount: Int) { + delta += amount + resetTimer?.invalidate() + resetTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + withAnimation(.easeOut(duration: 0.3)) { + self?.delta = 0 + } + } + } + + func reset() { + resetTimer?.invalidate() + delta = 0 + } +} + /** * A control for adjusting a large numerical value (like Life). + * Shows delta indicators and supports long-press for custom delta input. */ struct LifeCounterControl: View { let value: Int let onDecrease: () -> Void let onIncrease: () -> Void - + var onCustomDelta: ((Int) -> Void)? = nil + + @StateObject private var deltaTracker = DeltaTracker() + @State private var showingCustomDeltaAlert = false + @State private var customDeltaText = "" + @State private var isIncreasing = true + var body: some View { HStack(spacing: 16) { - CircleButton(icon: "minus", color: .red, action: onDecrease) - + ZStack { + CircleButton( + icon: "minus", + color: .red, + action: { + onDecrease() + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(-1) + } + }, + onLongPress: { + isIncreasing = false + customDeltaText = "" + showingCustomDeltaAlert = true + Haptics.play(.medium) + } + ) + + if deltaTracker.delta < 0 { + DeltaIndicator(delta: deltaTracker.delta, alignment: .trailing) + .offset(y: -35) + } + } + Text("\(value)") .font(.system(size: 56, weight: .bold, design: .rounded)) .minimumScaleFactor(0.5) .lineLimit(1) .foregroundStyle(.primary) - - CircleButton(icon: "plus", color: .green, action: onIncrease) + + ZStack { + CircleButton( + icon: "plus", + color: .green, + action: { + onIncrease() + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(1) + } + }, + onLongPress: { + isIncreasing = true + customDeltaText = "" + showingCustomDeltaAlert = true + Haptics.play(.medium) + } + ) + + if deltaTracker.delta > 0 { + DeltaIndicator(delta: deltaTracker.delta, alignment: .leading) + .offset(y: -35) + } + } } .padding(.horizontal, 16) + .alert("Enter Amount", isPresented: $showingCustomDeltaAlert) { + TextField("Amount", text: $customDeltaText) + .keyboardType(.numberPad) + Button("Cancel", role: .cancel) { } + Button(isIncreasing ? "Add" : "Subtract") { + if let amount = Int(customDeltaText), amount > 0 { + let delta = isIncreasing ? amount : -amount + onCustomDelta?(delta) + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(delta) + } + } + } + } message: { + Text(isIncreasing ? "Enter the amount to add" : "Enter the amount to subtract") + } } } /** * A smaller control for adjusting secondary values (like Poison). + * Shows delta indicators and supports long-press for custom delta input. */ struct SmallCounterControl: View { let value: Int @@ -68,11 +187,41 @@ struct SmallCounterControl: View { let color: Color let onDecrease: () -> Void let onIncrease: () -> Void - + var onCustomDelta: ((Int) -> Void)? = nil + + @StateObject private var deltaTracker = DeltaTracker() + @State private var showingCustomDeltaAlert = false + @State private var customDeltaText = "" + @State private var isIncreasing = true + var body: some View { HStack(spacing: 4) { - CircleButton(icon: "minus", color: .gray, size: 24, action: onDecrease) - + ZStack { + CircleButton( + icon: "minus", + color: .gray, + size: 24, + action: { + onDecrease() + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(-1) + } + }, + onLongPress: { + isIncreasing = false + customDeltaText = "" + showingCustomDeltaAlert = true + Haptics.play(.medium) + } + ) + + if deltaTracker.delta < 0 { + DeltaIndicator(delta: deltaTracker.delta, alignment: .trailing) + .font(.system(size: 12, weight: .bold)) + .offset(y: -22) + } + } + VStack(spacing: 0) { Image(systemName: icon) .font(.caption2) @@ -82,12 +231,52 @@ struct SmallCounterControl: View { .foregroundStyle(color) } .frame(minWidth: 30) - - CircleButton(icon: "plus", color: .gray, size: 24, action: onIncrease) + + ZStack { + CircleButton( + icon: "plus", + color: .gray, + size: 24, + action: { + onIncrease() + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(1) + } + }, + onLongPress: { + isIncreasing = true + customDeltaText = "" + showingCustomDeltaAlert = true + Haptics.play(.medium) + } + ) + + if deltaTracker.delta > 0 { + DeltaIndicator(delta: deltaTracker.delta, alignment: .leading) + .font(.system(size: 12, weight: .bold)) + .offset(y: -22) + } + } } .padding(6) .background(color.opacity(0.1)) .cornerRadius(12) + .alert("Enter Amount", isPresented: $showingCustomDeltaAlert) { + TextField("Amount", text: $customDeltaText) + .keyboardType(.numberPad) + Button("Cancel", role: .cancel) { } + Button(isIncreasing ? "Add" : "Subtract") { + if let amount = Int(customDeltaText), amount > 0 { + let delta = isIncreasing ? amount : -amount + onCustomDelta?(delta) + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(delta) + } + } + } + } message: { + Text(isIncreasing ? "Enter the amount to add" : "Enter the amount to subtract") + } } } @@ -99,7 +288,7 @@ struct SettingSlider: View { let value: Binding let range: ClosedRange let step: Double - + var body: some View { VStack(alignment: .leading) { HStack { @@ -122,7 +311,7 @@ enum Haptics { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() } - + static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) { guard UserDefaults.standard.bool(forKey: "hapticFeedbackEnabled") else { return } let generator = UINotificationFeedbackGenerator() diff --git a/ios/MagicCounter/GameView.swift b/ios/MagicCounter/GameView.swift index 026a3c7..ac7435a 100644 --- a/ios/MagicCounter/GameView.swift +++ b/ios/MagicCounter/GameView.swift @@ -22,12 +22,12 @@ struct GameView: View { @State private var draggedPlayer: PlayerState? @State private var elapsedTime: TimeInterval = 0 let match: MatchRecord - + init(match: MatchRecord) { self.match = match self._gameState = State(initialValue: match.state) } - + var body: some View { NavigationStack { GeometryReader { geometry in @@ -41,7 +41,7 @@ struct GameView: View { .lineLimit(1) .truncationMode(.tail) .foregroundStyle(.white) - + if !gameState.stopped && gameState.winner == nil { Text(timeString(from: elapsedTime)) .font(.subheadline.bold()) @@ -61,12 +61,12 @@ struct GameView: View { .background(.secondary.opacity(0.2)) .clipShape(Capsule()) } - + Spacer() } .padding(.horizontal, 24) .padding(.top, 16) - + if gameState.stopped { Text("Game Stopped") .font(.headline) @@ -78,7 +78,7 @@ struct GameView: View { .foregroundStyle(.green) .padding(.horizontal, 24) } - + LazyVGrid(columns: columns, spacing: 24) { ForEach(gameState.players) { player in PlayerCell( @@ -87,13 +87,12 @@ struct GameView: View { isWinner: gameState.winner?.id == player.id, onUpdate: updatePlayer, onCommanderTap: { selectedPlayerForCommander = player }, - onScoop: { scoopPlayer(player) } + onScoop: { scoopPlayer(player) }, + onDragStart: { + self.draggedPlayer = player + } ) .frame(height: 260) - .onDrag { - self.draggedPlayer = player - return NSItemProvider(object: String(player.id) as NSString) - } .onDrop(of: [.text], delegate: PlayerDropDelegate(item: player, items: $gameState.players, draggedItem: $draggedPlayer)) } } @@ -109,7 +108,7 @@ struct GameView: View { Image(systemName: "xmark") } } - + ToolbarItem(placement: .topBarTrailing) { if !gameState.stopped && gameState.winner == nil { Button(action: { showingStopConfirmation = true }) { @@ -151,7 +150,7 @@ struct GameView: View { gameManager.updateActiveGame(state: newState) } } - + private func updateElapsedTime() { if !gameState.stopped && gameState.winner == nil { elapsedTime = Date().timeIntervalSince(match.startedAt) @@ -159,7 +158,7 @@ struct GameView: View { elapsedTime = match.lastUpdated.timeIntervalSince(match.startedAt) } } - + private func timeString(from timeInterval: TimeInterval) -> String { let hours = Int(timeInterval) / 3600 let minutes = Int(timeInterval) / 60 % 60 @@ -170,23 +169,23 @@ struct GameView: View { return String(format: "%02i:%02i", minutes, seconds) } } - + private func updatePlayer(player: PlayerState) { if gameState.stopped || gameState.winner != nil { return } - + if let index = gameState.players.firstIndex(where: { $0.id == player.id }) { gameState.players[index] = player } - + // Update the sheet state if this is the player being edited to ensure the view refreshes if selectedPlayerForCommander?.id == player.id { selectedPlayerForCommander = player } } - + private func scoopPlayer(_ player: PlayerState) { if gameState.stopped || gameState.winner != nil { return } - + var newPlayer = player newPlayer.scooped = true updatePlayer(player: newPlayer) @@ -210,9 +209,10 @@ struct PlayerCell: View { let onUpdate: (PlayerState) -> Void let onCommanderTap: () -> Void let onScoop: () -> Void - + var onDragStart: (() -> Void)? = nil + @State private var showScoopConfirmation = false - + var body: some View { ZStack { RoundedRectangle(cornerRadius: 24) @@ -222,35 +222,42 @@ struct PlayerCell: View { RoundedRectangle(cornerRadius: 24) .stroke(isWinner ? Color.green : Color.clear, lineWidth: 3) ) - + VStack(spacing: 12) { - // Header + // Header - drag handle area HStack { Text(player.name) - .font(.headline) + .font(.system(size: 22, weight: .semibold, design: .rounded)) .lineLimit(1) - .minimumScaleFactor(0.8) + .minimumScaleFactor(0.7) .foregroundStyle(.primary) + .onDrag { + onDragStart?() + return NSItemProvider(object: String(player.id) as NSString) + } + Spacer() - + Button(action: { showScoopConfirmation = true }) { Image(systemName: "flag.fill") + .font(.system(size: 18)) .foregroundStyle(.secondary) - .padding(4) + .padding(8) } } .padding(.horizontal) .padding(.top, 12) - + // Life LifeCounterControl( value: player.life, onDecrease: { adjustLife(by: -1) }, - onIncrease: { adjustLife(by: 1) } + onIncrease: { adjustLife(by: 1) }, + onCustomDelta: { adjustLife(by: $0) } ) - + Spacer() - + // Counters Row HStack(spacing: 12) { if gameState.trackPoison { @@ -259,10 +266,11 @@ struct PlayerCell: View { icon: "drop.fill", color: .purple, onDecrease: { adjustPoison(by: -1) }, - onIncrease: { adjustPoison(by: 1) } + onIncrease: { adjustPoison(by: 1) }, + onCustomDelta: { adjustPoison(by: $0) } ) } - + if gameState.trackCommanderDamage { Button(action: onCommanderTap) { VStack(spacing: 2) { @@ -280,7 +288,7 @@ struct PlayerCell: View { } .padding(.bottom, 16) } - + if player.isEliminated && !isWinner { ZStack { Color.black.opacity(0.6) @@ -304,32 +312,129 @@ struct PlayerCell: View { Text("Are you sure you want to scoop?") } } - + private func adjustLife(by amount: Int) { if player.isEliminated { return } Haptics.play(.light) var newPlayer = player newPlayer.life += amount onUpdate(newPlayer) - + if newPlayer.isEliminated && !player.isEliminated { Haptics.notification(.error) } } - + private func adjustPoison(by amount: Int) { if player.isEliminated { return } Haptics.play(.light) var newPlayer = player newPlayer.poison = max(0, newPlayer.poison + amount) onUpdate(newPlayer) - + if newPlayer.isEliminated && !player.isEliminated { Haptics.notification(.error) } } } +/** + * A row for managing commander damage from a single attacker. + * Shows delta indicators and supports long-press for custom delta input. + */ +struct CommanderDamageRow: View { + let attackerName: String + let attackerId: Int + let currentDamage: Int + let onAdjust: (Int) -> Void + + @StateObject private var deltaTracker = DeltaTracker() + @State private var showingCustomDeltaAlert = false + @State private var customDeltaText = "" + @State private var isIncreasing = true + + var body: some View { + HStack { + Text(attackerName) + .font(.headline) + Spacer() + HStack(spacing: 16) { + ZStack { + CircleButton( + icon: "minus", + color: .secondary, + action: { + onAdjust(-1) + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(-1) + } + }, + onLongPress: { + isIncreasing = false + customDeltaText = "" + showingCustomDeltaAlert = true + Haptics.play(.medium) + } + ) + .buttonStyle(.borderless) + + if deltaTracker.delta < 0 { + DeltaIndicator(delta: deltaTracker.delta, alignment: .trailing) + .offset(y: -30) + } + } + + Text("\(currentDamage)") + .font(.title.bold()) + .frame(minWidth: 40) + .multilineTextAlignment(.center) + + ZStack { + CircleButton( + icon: "plus", + color: .primary, + action: { + onAdjust(1) + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(1) + } + }, + onLongPress: { + isIncreasing = true + customDeltaText = "" + showingCustomDeltaAlert = true + Haptics.play(.medium) + } + ) + .buttonStyle(.borderless) + + if deltaTracker.delta > 0 { + DeltaIndicator(delta: deltaTracker.delta, alignment: .leading) + .offset(y: -30) + } + } + } + } + .padding(.vertical, 8) + .alert("Enter Amount", isPresented: $showingCustomDeltaAlert) { + TextField("Amount", text: $customDeltaText) + .keyboardType(.numberPad) + Button("Cancel", role: .cancel) { } + Button(isIncreasing ? "Add" : "Subtract") { + if let amount = Int(customDeltaText), amount > 0 { + let delta = isIncreasing ? amount : -amount + onAdjust(delta) + withAnimation(.easeOut(duration: 0.15)) { + deltaTracker.addDelta(delta) + } + } + } + } message: { + Text(isIncreasing ? "Enter the amount to add" : "Enter the amount to subtract") + } + } +} + /** * Sheet for managing commander damage received by a player. * @@ -342,43 +447,25 @@ struct CommanderDamageView: View { let targetPlayer: PlayerState let gameState: GameState let onUpdate: (PlayerState) -> Void - + init(targetPlayer: PlayerState, gameState: GameState, onUpdate: @escaping (PlayerState) -> Void) { self.targetPlayer = targetPlayer self.gameState = gameState self.onUpdate = onUpdate } - + var body: some View { NavigationStack { List { ForEach(gameState.players.filter { $0.id != targetPlayer.id }) { attacker in - HStack { - Text(attacker.name) - .font(.headline) - Spacer() - HStack(spacing: 16) { - CircleButton( - icon: "minus", - color: .secondary, - action: { adjustCommanderDamage(attackerId: attacker.id, by: -1) } - ) - .buttonStyle(.borderless) - - Text("\(targetPlayer.commanderDamages[attacker.id] ?? 0)") - .font(.title.bold()) - .frame(minWidth: 40) - .multilineTextAlignment(.center) - - CircleButton( - icon: "plus", - color: .primary, - action: { adjustCommanderDamage(attackerId: attacker.id, by: 1) } - ) - .buttonStyle(.borderless) + CommanderDamageRow( + attackerName: attacker.name, + attackerId: attacker.id, + currentDamage: targetPlayer.commanderDamages[attacker.id] ?? 0, + onAdjust: { amount in + adjustCommanderDamage(attackerId: attacker.id, by: amount) } - } - .padding(.vertical, 8) + ) } } .navigationTitle("Commander Damage to \(targetPlayer.name)") @@ -390,30 +477,30 @@ struct CommanderDamageView: View { } } } - + private func adjustCommanderDamage(attackerId: Int, by amount: Int) { Haptics.play(.light) let wasEliminated = targetPlayer.isEliminated - + // Get current damage value let current = targetPlayer.commanderDamages[attackerId] ?? 0 let newDamage = max(0, current + amount) - + // Only proceed if there's an actual change guard newDamage != current else { return } - + // Update commander damages var updatedPlayer = targetPlayer var damages = updatedPlayer.commanderDamages damages[attackerId] = newDamage updatedPlayer.commanderDamages = damages - + // Adjust life total by the damage change updatedPlayer.life -= amount - + // Notify parent to update onUpdate(updatedPlayer) - + if updatedPlayer.isEliminated && !wasEliminated { Haptics.notification(.error) } @@ -441,8 +528,8 @@ struct PlayerDropDelegate: DropDelegate { } } } - + func dropUpdated(info: DropInfo) -> DropProposal? { return DropProposal(operation: .move) } -} \ No newline at end of file +}