From cde4b41ade883a4738ee0a5c4e97be17d49c8fa0 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 2 Jan 2026 22:23:51 -0700 Subject: [PATCH] iOS and Android Updates! --- android/.idea/.name | 1 + android/.idea/misc.xml | 1 - android/.idea/vcs.xml | 1 + android/app/build.gradle.kts | 4 +- .../magiccounter/ui/MagicCounterApp.kt | 62 +- .../magiccounter/ui/screens/GameScreen.kt | 1007 ++++++++++++++--- .../magiccounter/ui/state/GameState.kt | 5 +- .../magiccounter/ui/theme/CustomIcons.kt | 19 + android/gradle/libs.versions.toml | 34 +- ios/MagicCounter.xcodeproj/project.pbxproj | 8 +- .../UserInterfaceState.xcuserstate | Bin 46442 -> 49248 bytes ios/MagicCounter/Components.swift | 233 +++- ios/MagicCounter/GameView.swift | 235 ++-- 13 files changed, 1283 insertions(+), 327 deletions(-) create mode 100644 android/.idea/.name 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 96a97bfd713e17a12d45442dfdb3675a1f1e971b..77db9b97822f03fca446e1206b48ab86cad01f80 100644 GIT binary patch delta 24351 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 RQwK64V#?V)Q~qDC{XYd*Jx~Au delta 22407 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
joPJTLfDN+XmYQ zI|e%ky9SR84h#+s9upiD92-0~I5Buia9eO^@VelQ!CQj22k#9&5PUfJSn!v@Z$buy z_=kjrB!x@}nG{kP0z=wD7KW?}Ss$_~WNXN-kS{{^g&YX^CgfbmHCf2@ke@=HggguR zHRMgmA0h8TK7=x%hM}&ZLqms$dWHIi`h^CD28WIbjR=hnjSDRZofujaS`u0oS`k_u zIxVy&v^JCrofSGKv@`T*=#OKBV;siBjj0*4V$9(&x5vB-!^4DOqA(_`UzkalS(rtb zRhUhfUD&WN@34_!{$askvN2&1VNqd)VU1yn!521ibaoFBO+^2^Axk>5sMio6neJMv-VqsS+b zPa~g4{u=o)3X3A6=qQ~iy(ojI{!v4tlA09~TrC99JB-BJOD1>9}ig zkK>-ky@|)-`^Ou`+r&G>JIA}l50CeZ_lX}F9~mDLpB|qRpBFzqeo}mKd|7;XJd9Vy zcgL@ZUmw3Ierx=W_}%e);}66ijz1QEEnapb{#N|$_`C7<<9~{O6#pdtS^SImmt*^k zbsif(wqoqUv4_UqOAsWOC5%i6NeD}bOo&cMPRLG>BuEnq5(*P0B@`!2Pmm?dN|>7< zPf#Q@C$uK)PPmkCH{oHz%Z#6J?>CecYul1`Fwl39{vl1-9Bl2ejvl6z7>Qgo6eDL-jK z(xjx4q{&H@NmG)hC)Fl#N$p9Ck~)%>CM{3uOj?z+CTU&L#-z)m4iZrDlWnxNkN?FR>l)4moN@I#Lr6r{;WkJfKlqD(4 zQdXoKl%?EC`83XAT*$ad$kNUcw8PUTYrCrT+mW_A?ReU$G}$+4=h7~vT}k^c?MB+8v^Qz* z(mtkrN+;5(ba6VHZj?SK-6Y*Sol94wx1}#k??_*kzAAlf`iAt)>08t9Wawm!$nehy z$_U9AlQAwMJtHe4HzP0O%Z#%bH!|*I{E%@!2=P`MG^7Qli78ZM2J#!AOZ zlceLM>C!A|u2d>5kWQ2qOUtAc(rW26=?tk%I!iiNS|@FgDy1#bHt9lXhjf{ArA*o_ zT_asD-6Y*A-67pA-77sHJuE#YJs~|M{YH9DdO>C>FlIvdgvbdo6IM>xJYnmE?Gttt3JVPi`xg!@G%lP_SXDTsa9ZJv!t;f< z3U3$QExbQ**u=nzqbG(;44b%MV)w+=6W31MF!94A(IjS)&Lq7_l1ZhLCQquER6Xg$ zq>HjimnU7FbiK&7$fIa@k!O)lQDf2Kq9sMkidGgqFM40}vFKAVR-9OzTbx&%Up%3B zfAOi})5T|s&zG2&IG4DVxR(qonN`wM(o({gEGT(Y@}}gElD8!vN@GgXOEXKeOC_b- zN)MGDDLqztqHI8!RhdnhU717K)Uvr{^ULbX8p~vN%6=((R`#Oox5>ei<0mIfPMSP! z@`lNKChwbkVDjN|y>gRsvvP}a>+Vc%t)P)F=NY&12YcII5OjSZNFNJ zTB}-{+99>mYv|LlRY zjb|6lo;JH?cI|9#_NCc(XWyIsVD`f~UUNd`jF}TYCu+{}IUDC}p0jn1Y{y)|T%EbP zbM@!;n>&7P#oVg7Q|3;edv@-Pxwq!tp8LbRq4WIb1gVQQFQ@zt#vEvR@Qaab=PgH z+g`V$Zdcv0x)XJ$>b|KvTX(+hUfuJ$m$JIob$`^ot$SZj*E98f>h
igGQ*4xw% zsdub*u6L~;SwE`YuRfqYx;~~pwm!Z-t3JCvr(RNDT3=Q_xxS*FtDjvzufD#%p^y z`84@-d5yeQE|)9h&2nDeE?+2LC0{LHBVQ-qE&oEkN4`&fQhr)~R{pL0qWrS_2l+ku zeff{_m-5&0KjiP^ALM^EFb!;jj;ukqVQ_;~A>L@J++Hh6@ds8m=_lYq;O=py8*6-x}UD{Mqon;bX(6Mz&F>u}`C3qe-J_qs*+) zqS2+%y>VEhXQOwcZ(~?vcwGHpO2lMxrt3|&n(j3H(DbwC1-?Xr0_Ty|uP= zUTafpYwMEMRjq4VceS2s{igL?>xI_Kt=C#_w0_@uxAlJOo7O*C-?e^d{lsHD$ZTrynsU2%4+V$HF+XuCqwp+9hX?JXQX?Jh;XwPVu zw|~)or2Sa?`Sz>r*V}Kk-)aA;{Zacb?a$kPZGXE!ut2mxza!VY!>Yrn!>1##BdjB- zL(-AoF`;8pM@h%zj>-<$F{fjGhrC14(cHmzw0G?9xVuEO#AC_GB_T^ 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 +}