3 Commits
0.1.1 ... 1.0.0

Author SHA1 Message Date
98bf835815 1.0.0 2025-08-11 00:40:19 -06:00
e99db5fd60 Added proper styling for winners 2025-08-11 00:23:12 -06:00
35228d9374 0.2.0 - Bugfixes and deleting of matches 2025-08-10 21:40:49 -06:00
8 changed files with 265 additions and 211 deletions

View File

@@ -14,7 +14,7 @@ android {
minSdk = 35
targetSdk = 35
versionCode = 1
versionName = "0.1.1"
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -1,34 +1,38 @@
package com.atridad.magiccounter.ui
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.screens.GameScreen
import com.atridad.magiccounter.ui.screens.SetupScreen
@@ -38,21 +42,23 @@ import com.atridad.magiccounter.ui.settings.AppSettingsViewModel
import com.atridad.magiccounter.ui.settings.ThemeMode
import com.atridad.magiccounter.ui.settings.MatchRecord
import com.atridad.magiccounter.ui.theme.MagicCounterTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.FilterChip
// no-op
import androidx.activity.compose.BackHandler
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// Top-level navigation destinations
private enum class Screen { Home, Setup, Game }
private sealed class Screen {
data object Home : Screen()
data object Setup : Screen()
data object Settings : Screen()
data class Game(val matchId: String, val bootState: GameState? = null) : Screen()
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MagicCounterApp() {
var gameState by remember { mutableStateOf<GameState?>(null) }
var showSettings by remember { mutableStateOf(false) }
var activeMatchId by remember { mutableStateOf<String?>(null) }
var screen by remember { mutableStateOf(Screen.Home) }
val screenStack: SnapshotStateList<Screen> = remember { mutableStateListOf(Screen.Home) }
val settingsVm: AppSettingsViewModel = viewModel()
val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState()
@@ -63,33 +69,38 @@ fun MagicCounterApp() {
topBar = {
TopAppBar(
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
actions = {
if (screen != Screen.Home) {
IconButton(onClick = { screen = Screen.Home }) {
Icon(Icons.Default.Home, contentDescription = "Home")
navigationIcon = {
if (screenStack.size > 1) {
IconButton(onClick = { screenStack.removeLast() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
IconButton(onClick = { showSettings = true }) {
},
actions = {
IconButton(onClick = { screenStack.add(Screen.Settings) }) {
Icon(Icons.Default.Settings, contentDescription = "App settings")
}
}
)
}
) { paddingValues ->
when (screen) {
Screen.Home -> HomeScreen(
val currentScreen = screenStack.last()
BackHandler(enabled = screenStack.size > 1) { screenStack.removeLast() }
when (currentScreen) {
is Screen.Home -> HomeScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
history = historyState.value,
onNewGame = { screen = Screen.Setup },
onResume = { record ->
activeMatchId = record.id
gameState = record.state
screen = Screen.Game
}
onNewGame = { screenStack.add(Screen.Setup) },
onResume = { record -> screenStack.add(Screen.Game(record.id)) },
onDelete = { id ->
val newList = historyState.value.filterNot { it.id == id }
settingsVm.saveHistory(newList)
},
onClear = { settingsVm.saveHistory(emptyList()) }
)
Screen.Setup -> SetupScreen(
is Screen.Setup -> SetupScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
@@ -108,66 +119,68 @@ fun MagicCounterApp() {
)
val updated = historyState.value.toMutableList().apply { add(0, record) }
settingsVm.saveHistory(updated)
activeMatchId = newId
gameState = state
screen = Screen.Game
// Replace Setup with the new Game screen (pop then push)
if (screenStack.isNotEmpty()) screenStack.removeLast()
screenStack.add(Screen.Game(newId, state))
}
)
Screen.Game -> GameScreen(
is Screen.Game -> {
val id = currentScreen.matchId
val bootState = currentScreen.bootState
val record = historyState.value.firstOrNull { it.id == id }
val stateForGame = record?.state ?: bootState
if (stateForGame == null) {
screenStack.removeLast()
} else {
GameScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
state = stateForGame,
onProgress = { updated ->
val current = historyState.value
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(state = updated, lastUpdatedEpochMs = System.currentTimeMillis())
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
},
onWinner = { winnerId, finalState ->
val current = historyState.value
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(
state = finalState,
lastUpdatedEpochMs = System.currentTimeMillis(),
ongoing = false,
winnerPlayerId = winnerId
)
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
}
)
}
}
is Screen.Settings -> SettingsContent(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
state = gameState!!,
onEnd = { screen = Screen.Home },
onProgress = { updated ->
val current = historyState.value
val id = activeMatchId
if (id != null) {
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(state = updated, lastUpdatedEpochMs = System.currentTimeMillis())
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
}
},
onWinner = { winnerId, finalState ->
val current = historyState.value
val id = activeMatchId
if (id != null) {
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(
state = finalState,
lastUpdatedEpochMs = System.currentTimeMillis(),
ongoing = false,
winnerPlayerId = winnerId
)
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
}
}
current = theme.value,
onSelect = { settingsVm.setTheme(it) }
)
}
if (showSettings) {
ModalBottomSheet(onDismissRequest = { showSettings = false }) {
SettingsSheet(
current = theme.value,
onSelect = { settingsVm.setTheme(it) }
)
}
}
}
}
}
@Composable
private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Theme")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeMode.values().forEach { mode ->
ThemeMode.entries.forEach { mode ->
FilterChip(
selected = current == mode,
onClick = { onSelect(mode) },
@@ -178,57 +191,116 @@ private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
}
}
@Composable
private fun HistorySheet(history: List<MatchRecord>, onResume: (MatchRecord) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Match history")
history.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val status = if (rec.ongoing) "Ongoing" else "Finished"
Text("$status • Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
rec.winnerPlayerId?.let { Text("Winner: Player ${it + 1}") }
}
IconButton(onClick = { onResume(rec) }) {
Icon(Icons.Default.Refresh, contentDescription = "Resume")
}
}
}
}
}
@Composable
private fun HomeScreen(
modifier: Modifier,
history: List<MatchRecord>,
onNewGame: () -> Unit,
onResume: (MatchRecord) -> Unit
onResume: (MatchRecord) -> Unit,
onDelete: (String) -> Unit,
onClear: () -> Unit
) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNewGame) { Text("New game") }
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
var showClearConfirm by remember { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onNewGame) { Text("New game") }
Button(onClick = { showClearConfirm = true }) { Text("Clear history") }
}
Text("Ongoing")
history.filter { it.ongoing }.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
Text("Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
if (history.isEmpty()) {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.History, contentDescription = "Empty history")
Text("Nothing to see here")
Text("Start a new game to begin.")
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item {
Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp)) {
Text(
"Ongoing",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
HorizontalDivider()
}
}
items(history.filter { it.ongoing }, key = { it.id }) { rec ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
Text("Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
}
Row {
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") }
IconButton(onClick = { pendingDeleteId = rec.id }) { Icon(Icons.Default.Delete, contentDescription = "Delete") }
}
}
}
item {
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp)) {
Text(
"Finished",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
HorizontalDivider()
}
}
items(history.filter { !it.ongoing }, key = { it.id }) { rec ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner")
}
Row {
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") }
IconButton(onClick = { pendingDeleteId = rec.id }) { Icon(Icons.Default.Delete, contentDescription = "Delete") }
}
}
}
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") }
}
}
Text("Finished")
history.filter { !it.ongoing }.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner")
}
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") }
}
// Confirm delete dialog
val pending = history.firstOrNull { it.id == pendingDeleteId }
if (pendingDeleteId != null && pending != null) {
AlertDialog(
onDismissRequest = { pendingDeleteId = null },
title = { Text("Delete match?") },
text = { Text("Are you sure you want to delete \"${pending.name}\"?") },
confirmButton = {
TextButton(onClick = {
onDelete(pendingDeleteId!!)
pendingDeleteId = null
}) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { pendingDeleteId = null }) { Text("Cancel") } }
)
}
// Confirm clear dialog
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { showClearConfirm = false },
title = { Text("Clear history?") },
text = { Text("This will remove all matches. This action cannot be undone.") },
confirmButton = {
TextButton(onClick = {
onClear()
showClearConfirm = false
}) { Text("Clear") }
},
dismissButton = { TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } }
)
}
}
}

View File

@@ -21,14 +21,15 @@ import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,32 +42,35 @@ 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 java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalFoundationApi::class)
@Composable
/**
* Game screen hosting all player panels.
*
* - state: immutable state used to bootstrap the in-memory counters
* - onProgress: called with a snapshot of the latest state after any user interaction
* - onWinner: called once when a winner is determined, with (winnerId, finalState)
*/
fun GameScreen(
modifier: Modifier = Modifier,
state: GameState,
onEnd: () -> Unit,
onProgress: ((GameState) -> Unit)? = null,
onWinner: ((Int, GameState) -> Unit)? = null
) {
// Local editable state per player
val lifeTotals = remember { mutableStateMapOf<Int, Int>() }
val poisonTotals = remember { mutableStateMapOf<Int, Int>() }
val energyTotals = remember { mutableStateMapOf<Int, Int>() }
val experienceTotals = remember { mutableStateMapOf<Int, Int>() }
val commanderDamages = remember { mutableStateMapOf<Int, MutableMap<Int, Int>>() }
val eliminated = remember { mutableStateMapOf<Int, Boolean>() }
val gameLocked = remember { mutableStateMapOf<String, Boolean>() } // single key used to freeze when winner determined
// Tracks whether the game has ended. When true, inputs are frozen and the winner is highlighted.
val gameLocked = remember { mutableStateOf(false) }
// Initialize
state.players.forEach { p ->
lifeTotals.putIfAbsent(p.id, p.life)
poisonTotals.putIfAbsent(p.id, p.poison)
energyTotals.putIfAbsent(p.id, p.energy)
experienceTotals.putIfAbsent(p.id, p.experience)
commanderDamages.putIfAbsent(p.id, p.commanderDamages.toMutableMap())
eliminated.putIfAbsent(p.id, p.scooped)
}
@@ -83,9 +87,6 @@ fun GameScreen(
}
}
// Single column for maximum width
val numColumns = 1
fun snapshotState(): GameState = state.copy(
players = state.players.map { p ->
PlayerState(
@@ -93,8 +94,6 @@ fun GameScreen(
name = p.name,
life = lifeTotals[p.id] ?: p.life,
poison = poisonTotals[p.id] ?: p.poison,
energy = energyTotals[p.id] ?: p.energy,
experience = experienceTotals[p.id] ?: p.experience,
commanderDamages = commanderDamages[p.id]?.toMap() ?: p.commanderDamages,
scooped = eliminated[p.id] == true
)
@@ -104,12 +103,13 @@ fun GameScreen(
Column(modifier = modifier.padding(12.dp)) {
// Lock game when only one active player remains
val aliveCount = state.players.count { eliminated[it.id] != true }
if (aliveCount == 1) {
val winnerId = state.players.first { eliminated[it.id] != true }.id
if (gameLocked["locked"] != true) {
gameLocked["locked"] = true
onWinner?.invoke(winnerId, snapshotState())
}
val currentWinnerId: Int? = if (aliveCount == 1) {
state.players.first { eliminated[it.id] != true }.id
} else null
if (currentWinnerId != null && !gameLocked.value) {
gameLocked.value = true
onWinner?.invoke(currentWinnerId, snapshotState())
}
val displayPlayers = state.players.sortedBy { eliminated[it.id] == true }
@@ -126,39 +126,23 @@ fun GameScreen(
opponents = state.players.map { it.id }.filter { it != player.id },
life = lifeTotals[player.id] ?: state.startingLife,
onLifeChange = { new ->
if (gameLocked["locked"] == true) return@PlayerCard
if (gameLocked.value) return@PlayerCard
lifeTotals[player.id] = new
checkElimination(player.id)
onProgress?.invoke(snapshotState())
},
poison = poisonTotals[player.id] ?: 0,
onPoisonChange = {
if (gameLocked["locked"] != true) {
if (!gameLocked.value) {
poisonTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
energy = energyTotals[player.id] ?: 0,
onEnergyChange = {
if (gameLocked["locked"] != true) {
energyTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
experience = experienceTotals[player.id] ?: 0,
onExperienceChange = {
if (gameLocked["locked"] != true) {
experienceTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
trackPoison = state.trackPoison,
trackEnergy = state.trackEnergy,
trackExperience = state.trackExperience,
trackCommanderDamage = state.trackCommanderDamage,
commanderDamages = perPlayerCommander,
onCommanderDamageChange = { fromId, dmg ->
if (gameLocked["locked"] != true) {
if (!gameLocked.value) {
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
newMap[fromId] = dmg
commanderDamages[player.id] = newMap
@@ -169,8 +153,9 @@ fun GameScreen(
rotation = 0f,
accentColor = accent,
isEliminated = eliminated[player.id] == true,
isWinner = currentWinnerId == player.id,
onScoop = {
if (gameLocked["locked"] != true) {
if (!gameLocked.value) {
eliminated[player.id] = true
onProgress?.invoke(snapshotState())
}
@@ -190,6 +175,22 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color
else -> scheme.tertiaryContainer
}
/**
* 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,
@@ -198,24 +199,20 @@ private fun PlayerCard(
onLifeChange: (Int) -> Unit,
poison: Int,
onPoisonChange: (Int) -> Unit,
energy: Int,
onEnergyChange: (Int) -> Unit,
experience: Int,
onExperienceChange: (Int) -> Unit,
trackPoison: Boolean,
trackEnergy: Boolean,
trackExperience: Boolean,
trackCommanderDamage: Boolean,
commanderDamages: Map<Int, Int>,
onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit,
rotation: Float,
accentColor: Color,
isEliminated: Boolean,
isWinner: Boolean,
onScoop: () -> Unit
) {
val targetContainer = if (isEliminated) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant
val containerColor = animateColorAsState(targetValue = targetContainer, label = "eliminationColor")
val overlayAlpha = animateFloatAsState(targetValue = if (isEliminated) 1f else 0f, label = "overlayAlpha")
val controlsEnabled = !isEliminated && !isWinner
Card(
modifier = Modifier
@@ -224,31 +221,29 @@ private fun PlayerCard(
.graphicsLayer { rotationZ = rotation },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(containerColor = containerColor.value),
border = BorderStroke(2.dp, accentColor)
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 = accentColor)
TextButton(onClick = onScoop, enabled = !isEliminated) {
Text(player.name, style = MaterialTheme.typography.titleMedium, color = if (isWinner) Color(0xFF2E7D32) else accentColor)
TextButton(onClick = onScoop, enabled = controlsEnabled) {
Icon(Icons.Default.Flag, contentDescription = "Scoop")
Text("Scoop")
}
}
BigLifeRow(value = life, onChange = onLifeChange, enabled = !isEliminated)
BigLifeRow(value = life, onChange = onLifeChange, enabled = controlsEnabled)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = !isEliminated)
if (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange, enabled = !isEliminated)
if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange, enabled = !isEliminated)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = controlsEnabled)
if (trackCommanderDamage) {
Divider()
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 = !isEliminated)
ChipRow(label = "From P${fromId + 1}", value = value, onChange = { onCommanderDamageChange(fromId, it) }, enabled = controlsEnabled)
}
}
}
@@ -267,15 +262,19 @@ private fun PlayerCard(
}
}
private fun playerOpponents(selfId: Int, ids: Collection<Int>): List<Int> =
ids.filter { it != selfId }.sorted()
/**
* 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
*/
@Composable
private fun ChipRow(
label: String,
value: Int,
onChange: (Int) -> Unit,
emphasized: Boolean = false,
enabled: Boolean = true
) {
Row(
@@ -297,6 +296,13 @@ private fun ChipRow(
}
}
/**
* Life total row with number and +/- buttons.
*
* - value: current life total
* - onChange: callback with new life total
* - enabled: when false, buttons are disabled
*/
@Composable
private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = true) {
Row(
@@ -318,6 +324,13 @@ private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = t
}
}
/**
* Icon button used for counters.
* - icon: vector asset to display
* - contentDescription: a11y description
* - enabled: controls click availability
* - onClick: invoked on press
*/
@Composable
private fun CounterIconButton(icon: ImageVector, contentDescription: String, enabled: Boolean = true, onClick: () -> Unit) {
FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {

View File

@@ -1,7 +1,5 @@
package com.atridad.magiccounter.ui.screens
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -32,7 +30,6 @@ import com.atridad.magiccounter.ui.state.PlayerState
import com.atridad.magiccounter.ui.state.defaultPlayerName
import kotlin.math.roundToInt
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun SetupScreen(
modifier: Modifier = Modifier,
@@ -41,8 +38,6 @@ fun SetupScreen(
var playerCount by remember { mutableIntStateOf(4) }
var startingLife by remember { mutableIntStateOf(40) }
var trackPoison by remember { mutableStateOf(true) }
var trackEnergy by remember { mutableStateOf(false) }
var trackExperience by remember { mutableStateOf(false) }
var trackCommander by remember { mutableStateOf(true) }
var matchName by remember { mutableStateOf("") }
@@ -81,14 +76,6 @@ fun SetupScreen(
Checkbox(checked = trackPoison, onCheckedChange = { trackPoison = it })
Text("Track poison")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackEnergy, onCheckedChange = { trackEnergy = it })
Text("Track energy")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackExperience, onCheckedChange = { trackExperience = it })
Text("Track experience")
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
@@ -109,6 +96,7 @@ fun SetupScreen(
}
Spacer(modifier = Modifier.height(8.dp))
val canStart = matchName.isNotBlank()
Button(onClick = {
val players = names.mapIndexed { index, name ->
PlayerState(
@@ -116,23 +104,19 @@ fun SetupScreen(
name = name.ifBlank { defaultPlayerName(index) },
life = startingLife,
poison = 0,
energy = 0,
experience = 0,
commanderDamages = emptyMap()
)
}
onStart(
matchName.ifBlank { "Game ${System.currentTimeMillis()}" },
matchName,
GameState(
players = players,
startingLife = startingLife,
trackPoison = trackPoison,
trackEnergy = trackEnergy,
trackExperience = trackExperience,
trackCommanderDamage = trackCommander
)
)
}) {
}, enabled = canStart) {
Text("Start game")
}
}

View File

@@ -1,13 +1,11 @@
package com.atridad.magiccounter.ui.settings
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -29,6 +27,8 @@ enum class ThemeMode { System, Light, Dark }
private val Context.dataStore by preferencesDataStore(name = "app_settings")
object AppSettingsRepository {
// Use a tolerant JSON instance to handle backward/forward compatible schema changes
private val json = Json { ignoreUnknownKeys = true }
private val THEME_MODE = intPreferencesKey("theme_mode")
private val MATCH_HISTORY = stringPreferencesKey("match_history_json")
@@ -51,18 +51,18 @@ object AppSettingsRepository {
}
}
// Very small JSON blob to persist match history
// JSON blob to persist match history
fun readMatchHistory(context: Context): Flow<List<MatchRecord>> =
context.dataStore.data.map { prefs ->
prefs[MATCH_HISTORY]?.let {
runCatching { Json.decodeFromString<List<MatchRecord>>(it) }.getOrDefault(emptyList())
runCatching { json.decodeFromString<List<MatchRecord>>(it) }.getOrDefault(emptyList())
} ?: emptyList()
}
suspend fun writeMatchHistory(context: Context, history: List<MatchRecord>) {
val json = Json.encodeToString(history)
val jsonStr = json.encodeToString(history)
context.dataStore.edit { prefs ->
prefs[MATCH_HISTORY] = json
prefs[MATCH_HISTORY] = jsonStr
}
}
}

View File

@@ -3,7 +3,6 @@ package com.atridad.magiccounter.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn

View File

@@ -2,15 +2,6 @@ package com.atridad.magiccounter.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
@Immutable
@Serializable
data class CommanderDamage(
val fromPlayerId: Int,
val damage: Int
)
@Immutable
@Serializable
@@ -19,8 +10,6 @@ data class PlayerState(
val name: String,
val life: Int,
val poison: Int,
val energy: Int,
val experience: Int,
val commanderDamages: Map<Int, Int>,
val scooped: Boolean = false
)
@@ -31,8 +20,6 @@ data class GameState(
val players: List<PlayerState>,
val startingLife: Int,
val trackPoison: Boolean,
val trackEnergy: Boolean,
val trackExperience: Boolean,
val trackCommanderDamage: Boolean
)

View File

@@ -1,4 +1,3 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false