This commit is contained in:
2025-08-10 20:30:55 -06:00
parent 2b7752c5c3
commit 10e26b1fcd
18 changed files with 368 additions and 252 deletions

View File

@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@@ -13,7 +14,7 @@ android {
minSdk = 35 minSdk = 35
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -51,6 +52,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.ktorx.serialization.json)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

BIN
app/release/app-release.aab Normal file

Binary file not shown.

View File

@@ -10,6 +10,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh 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.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -31,19 +36,27 @@ import com.atridad.magiccounter.ui.state.GameState
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.atridad.magiccounter.ui.settings.AppSettingsViewModel import com.atridad.magiccounter.ui.settings.AppSettingsViewModel
import com.atridad.magiccounter.ui.settings.ThemeMode import com.atridad.magiccounter.ui.settings.ThemeMode
import com.atridad.magiccounter.ui.settings.MatchRecord
import com.atridad.magiccounter.ui.theme.MagicCounterTheme import com.atridad.magiccounter.ui.theme.MagicCounterTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
// no-op // no-op
// Top-level navigation destinations
private enum class Screen { Home, Setup, Game }
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MagicCounterApp() { fun MagicCounterApp() {
var gameState by remember { mutableStateOf<GameState?>(null) } var gameState by remember { mutableStateOf<GameState?>(null) }
var showSettings by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) }
var activeMatchId by remember { mutableStateOf<String?>(null) }
var screen by remember { mutableStateOf(Screen.Home) }
val settingsVm: AppSettingsViewModel = viewModel() val settingsVm: AppSettingsViewModel = viewModel()
val theme = settingsVm.themeMode.collectAsState() val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState()
MagicCounterTheme(themeMode = theme.value) { MagicCounterTheme(themeMode = theme.value) {
Scaffold( Scaffold(
@@ -51,9 +64,9 @@ fun MagicCounterApp() {
TopAppBar( TopAppBar(
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) }, title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
actions = { actions = {
if (gameState != null) { if (screen != Screen.Home) {
IconButton(onClick = { gameState = null }) { IconButton(onClick = { screen = Screen.Home }) {
Icon(Icons.Default.Refresh, contentDescription = "New game") Icon(Icons.Default.Home, contentDescription = "Home")
} }
} }
IconButton(onClick = { showSettings = true }) { IconButton(onClick = { showSettings = true }) {
@@ -63,20 +76,78 @@ fun MagicCounterApp() {
) )
} }
) { paddingValues -> ) { paddingValues ->
if (gameState == null) { when (screen) {
SetupScreen( Screen.Home -> HomeScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
onStart = { state -> gameState = state } history = historyState.value,
onNewGame = { screen = Screen.Setup },
onResume = { record ->
activeMatchId = record.id
gameState = record.state
screen = Screen.Game
}
) )
} else { Screen.Setup -> SetupScreen(
GameScreen( modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
onStart = { name, state ->
// Create and persist a new MatchRecord
val newId = java.util.UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val record = MatchRecord(
id = newId,
name = name,
startedAtEpochMs = now,
lastUpdatedEpochMs = now,
ongoing = true,
winnerPlayerId = null,
state = state
)
val updated = historyState.value.toMutableList().apply { add(0, record) }
settingsVm.saveHistory(updated)
activeMatchId = newId
gameState = state
screen = Screen.Game
}
)
Screen.Game -> GameScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
state = gameState!!, state = gameState!!,
onEnd = { gameState = null } 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)
}
}
}
) )
} }
if (showSettings) { if (showSettings) {
@@ -107,4 +178,59 @@ 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
) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNewGame) { Text("New game") }
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))}")
}
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") }
}
}
}
}

View File

@@ -1,18 +1,23 @@
package com.atridad.magiccounter.ui.screens package com.atridad.magiccounter.ui.screens
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -21,25 +26,31 @@ import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.GameState
import com.atridad.magiccounter.ui.state.PlayerState import com.atridad.magiccounter.ui.state.PlayerState
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun GameScreen( fun GameScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: GameState, state: GameState,
onEnd: () -> Unit onEnd: () -> Unit,
onProgress: ((GameState) -> Unit)? = null,
onWinner: ((Int, GameState) -> Unit)? = null
) { ) {
// Local editable state per player // Local editable state per player
val lifeTotals = remember { mutableStateMapOf<Int, Int>() } val lifeTotals = remember { mutableStateMapOf<Int, Int>() }
@@ -47,6 +58,8 @@ fun GameScreen(
val energyTotals = remember { mutableStateMapOf<Int, Int>() } val energyTotals = remember { mutableStateMapOf<Int, Int>() }
val experienceTotals = remember { mutableStateMapOf<Int, Int>() } val experienceTotals = remember { mutableStateMapOf<Int, Int>() }
val commanderDamages = remember { mutableStateMapOf<Int, MutableMap<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
// Initialize // Initialize
state.players.forEach { p -> state.players.forEach { p ->
@@ -55,43 +68,113 @@ fun GameScreen(
energyTotals.putIfAbsent(p.id, p.energy) energyTotals.putIfAbsent(p.id, p.energy)
experienceTotals.putIfAbsent(p.id, p.experience) experienceTotals.putIfAbsent(p.id, p.experience)
commanderDamages.putIfAbsent(p.id, p.commanderDamages.toMutableMap()) commanderDamages.putIfAbsent(p.id, p.commanderDamages.toMutableMap())
eliminated.putIfAbsent(p.id, p.scooped)
} }
// Single column for maximum width per request fun checkElimination(playerId: Int) {
val life = lifeTotals[playerId] ?: state.startingLife
if (life <= 0) {
eliminated[playerId] = true
return
}
val fromMap: Map<Int, Int> = commanderDamages[playerId] ?: emptyMap()
if (fromMap.values.any { it >= 21 }) {
eliminated[playerId] = true
}
}
// Single column for maximum width
val numColumns = 1 val numColumns = 1
fun snapshotState(): GameState = state.copy(
players = state.players.map { p ->
PlayerState(
id = p.id,
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
)
}
)
Column(modifier = modifier.padding(12.dp)) { 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 displayPlayers = state.players.sortedBy { eliminated[it.id] == true }
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
itemsIndexed(state.players, key = { _, item -> item.id }) { index, player -> itemsIndexed(displayPlayers, key = { _, item -> item.id }) { index, player ->
val accent = seatAccentColor(index, MaterialTheme.colorScheme) val accent = seatAccentColor(index, MaterialTheme.colorScheme)
val perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap() val perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap()
PlayerCard( PlayerCard(
player = player, player = player,
opponents = state.players.map { it.id }.filter { it != player.id }, opponents = state.players.map { it.id }.filter { it != player.id },
life = lifeTotals[player.id] ?: state.startingLife, life = lifeTotals[player.id] ?: state.startingLife,
onLifeChange = { lifeTotals[player.id] = it }, onLifeChange = { new ->
if (gameLocked["locked"] == true) return@PlayerCard
lifeTotals[player.id] = new
checkElimination(player.id)
onProgress?.invoke(snapshotState())
},
poison = poisonTotals[player.id] ?: 0, poison = poisonTotals[player.id] ?: 0,
onPoisonChange = { poisonTotals[player.id] = it }, onPoisonChange = {
if (gameLocked["locked"] != true) {
poisonTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
energy = energyTotals[player.id] ?: 0, energy = energyTotals[player.id] ?: 0,
onEnergyChange = { energyTotals[player.id] = it }, onEnergyChange = {
if (gameLocked["locked"] != true) {
energyTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
experience = experienceTotals[player.id] ?: 0, experience = experienceTotals[player.id] ?: 0,
onExperienceChange = { experienceTotals[player.id] = it }, onExperienceChange = {
if (gameLocked["locked"] != true) {
experienceTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
trackPoison = state.trackPoison, trackPoison = state.trackPoison,
trackEnergy = state.trackEnergy, trackEnergy = state.trackEnergy,
trackExperience = state.trackExperience, trackExperience = state.trackExperience,
trackCommanderDamage = state.trackCommanderDamage, trackCommanderDamage = state.trackCommanderDamage,
commanderDamages = perPlayerCommander, commanderDamages = perPlayerCommander,
onCommanderDamageChange = { fromId, dmg -> onCommanderDamageChange = { fromId, dmg ->
if (gameLocked["locked"] != true) {
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap() val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
newMap[fromId] = dmg newMap[fromId] = dmg
commanderDamages[player.id] = newMap commanderDamages[player.id] = newMap
checkElimination(player.id)
onProgress?.invoke(snapshotState())
}
}, },
rotation = 0f, rotation = 0f,
accentColor = accent accentColor = accent,
isEliminated = eliminated[player.id] == true,
onScoop = {
if (gameLocked["locked"] != true) {
eliminated[player.id] = true
onProgress?.invoke(snapshotState())
}
}
) )
} }
} }
@@ -126,25 +209,38 @@ private fun PlayerCard(
commanderDamages: Map<Int, Int>, commanderDamages: Map<Int, Int>,
onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit, onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit,
rotation: Float, rotation: Float,
accentColor: Color accentColor: Color,
isEliminated: 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")
Card( Card(
modifier = Modifier modifier = Modifier
.padding(4.dp) .padding(4.dp)
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { rotationZ = rotation }, .graphicsLayer { rotationZ = rotation },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = containerColor.value),
border = BorderStroke(2.dp, accentColor) border = BorderStroke(2.dp, accentColor)
) { ) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { 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) Text(player.name, style = MaterialTheme.typography.titleMedium, color = accentColor)
TextButton(onClick = onScoop, enabled = !isEliminated) {
Icon(Icons.Default.Flag, contentDescription = "Scoop")
Text("Scoop")
}
}
BigLifeRow(value = life, onChange = onLifeChange) BigLifeRow(value = life, onChange = onLifeChange, enabled = !isEliminated)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange) if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = !isEliminated)
if (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange) if (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange, enabled = !isEliminated)
if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange) if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange, enabled = !isEliminated)
if (trackCommanderDamage) { if (trackCommanderDamage) {
Divider() Divider()
@@ -152,11 +248,22 @@ private fun PlayerCard(
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
opponents.forEach { fromId -> opponents.forEach { fromId ->
val value = commanderDamages[fromId] ?: 0 val value = commanderDamages[fromId] ?: 0
ChipRow(label = "From P${fromId + 1}", value = value, onChange = { onCommanderDamageChange(fromId, it) }) ChipRow(label = "From P${fromId + 1}", value = value, onChange = { onCommanderDamageChange(fromId, it) }, enabled = !isEliminated)
} }
} }
} }
} }
if (isEliminated) {
// Skull overlay
Text(
text = "☠️",
fontSize = 64.sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value)
)
}
}
} }
} }
@@ -168,7 +275,8 @@ private fun ChipRow(
label: String, label: String,
value: Int, value: Int,
onChange: (Int) -> Unit, onChange: (Int) -> Unit,
emphasized: Boolean = false emphasized: Boolean = false,
enabled: Boolean = true
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -177,20 +285,20 @@ private fun ChipRow(
) { ) {
Text(label) Text(label)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CounterIconButton(Icons.Default.Remove, "decrement") { onChange(value - 1) } CounterIconButton(Icons.Default.Remove, "decrement", enabled = enabled) { onChange(value - 1) }
Text( Text(
text = value.toString(), text = value.toString(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
CounterIconButton(Icons.Default.Add, "increment") { onChange(value + 1) } CounterIconButton(Icons.Default.Add, "increment", enabled = enabled) { onChange(value + 1) }
} }
} }
} }
@Composable @Composable
private fun BigLifeRow(value: Int, onChange: (Int) -> Unit) { private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = true) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -198,21 +306,21 @@ private fun BigLifeRow(value: Int, onChange: (Int) -> Unit) {
) { ) {
Text("Life", style = MaterialTheme.typography.titleMedium) Text("Life", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CounterIconButton(Icons.Default.Remove, "decrement life") { onChange(value - 1) } CounterIconButton(Icons.Default.Remove, "decrement life", enabled = enabled) { onChange(value - 1) }
Text( Text(
text = value.toString(), text = value.toString(),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
CounterIconButton(Icons.Default.Add, "increment life") { onChange(value + 1) } CounterIconButton(Icons.Default.Add, "increment life", enabled = enabled) { onChange(value + 1) }
} }
} }
} }
@Composable @Composable
private fun CounterIconButton(icon: ImageVector, contentDescription: String, onClick: () -> Unit) { private fun CounterIconButton(icon: ImageVector, contentDescription: String, enabled: Boolean = true, onClick: () -> Unit) {
FilledTonalIconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
Icon(icon, contentDescription = contentDescription) Icon(icon, contentDescription = contentDescription)
} }
} }

View File

@@ -35,7 +35,7 @@ import com.atridad.magiccounter.ui.state.defaultPlayerName
@Composable @Composable
fun SetupScreen( fun SetupScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onStart: (GameState) -> Unit onStart: (String, GameState) -> Unit
) { ) {
var playerCount by remember { mutableIntStateOf(4) } var playerCount by remember { mutableIntStateOf(4) }
var startingLife by remember { mutableIntStateOf(40) } var startingLife by remember { mutableIntStateOf(40) }
@@ -43,6 +43,7 @@ fun SetupScreen(
var trackEnergy by remember { mutableStateOf(false) } var trackEnergy by remember { mutableStateOf(false) }
var trackExperience by remember { mutableStateOf(false) } var trackExperience by remember { mutableStateOf(false) }
var trackCommander by remember { mutableStateOf(true) } var trackCommander by remember { mutableStateOf(true) }
var matchName by remember { mutableStateOf("") }
val names = remember { mutableStateListOf<String>() } val names = remember { mutableStateListOf<String>() }
LaunchedEffect(playerCount) { LaunchedEffect(playerCount) {
@@ -80,6 +81,13 @@ fun SetupScreen(
Text("Track experience") Text("Track experience")
} }
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = matchName,
onValueChange = { matchName = it },
label = { Text("Match name") }
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
names.forEachIndexed { index, value -> names.forEachIndexed { index, value ->
OutlinedTextField( OutlinedTextField(
@@ -105,6 +113,7 @@ fun SetupScreen(
) )
} }
onStart( onStart(
matchName.ifBlank { "Game ${System.currentTimeMillis()}" },
GameState( GameState(
players = players, players = players,
startingLife = startingLife, startingLife = startingLife,

View File

@@ -4,9 +4,25 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
@Serializable
data class MatchRecord(
val id: String,
val name: String,
val startedAtEpochMs: Long,
val lastUpdatedEpochMs: Long,
val ongoing: Boolean,
val winnerPlayerId: Int? = null,
val state: com.atridad.magiccounter.ui.state.GameState
)
enum class ThemeMode { System, Light, Dark } enum class ThemeMode { System, Light, Dark }
@@ -14,6 +30,7 @@ private val Context.dataStore by preferencesDataStore(name = "app_settings")
object AppSettingsRepository { object AppSettingsRepository {
private val THEME_MODE = intPreferencesKey("theme_mode") private val THEME_MODE = intPreferencesKey("theme_mode")
private val MATCH_HISTORY = stringPreferencesKey("match_history_json")
fun themeMode(context: Context): Flow<ThemeMode> = fun themeMode(context: Context): Flow<ThemeMode> =
context.dataStore.data.map { prefs -> context.dataStore.data.map { prefs ->
@@ -33,6 +50,21 @@ object AppSettingsRepository {
} }
} }
} }
// Very small 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())
} ?: emptyList()
}
suspend fun writeMatchHistory(context: Context, history: List<MatchRecord>) {
val json = Json.encodeToString(history)
context.dataStore.edit { prefs ->
prefs[MATCH_HISTORY] = json
}
}
} }

View File

@@ -3,6 +3,7 @@ package com.atridad.magiccounter.ui.settings
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -21,6 +22,19 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
AppSettingsRepository.setThemeMode(getApplication(), mode) AppSettingsRepository.setThemeMode(getApplication(), mode)
} }
} }
val matchHistory: StateFlow<List<MatchRecord>> =
AppSettingsRepository.readMatchHistory(application).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun saveHistory(history: List<MatchRecord>) {
viewModelScope.launch {
AppSettingsRepository.writeMatchHistory(getApplication(), history)
}
}
} }

View File

@@ -1,14 +1,19 @@
package com.atridad.magiccounter.ui.state package com.atridad.magiccounter.ui.state
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
@Immutable @Immutable
@Serializable
data class CommanderDamage( data class CommanderDamage(
val fromPlayerId: Int, val fromPlayerId: Int,
val damage: Int val damage: Int
) )
@Immutable @Immutable
@Serializable
data class PlayerState( data class PlayerState(
val id: Int, val id: Int,
val name: String, val name: String,
@@ -16,10 +21,12 @@ data class PlayerState(
val poison: Int, val poison: Int,
val energy: Int, val energy: Int,
val experience: Int, val experience: Int,
val commanderDamages: Map<Int, Int> val commanderDamages: Map<Int, Int>,
val scooped: Boolean = false
) )
@Immutable @Immutable
@Serializable
data class GameState( data class GameState(
val players: List<PlayerState>, val players: List<PlayerState>,
val startingLife: Int, val startingLife: Int,

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -1,30 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <?xml version="1.0" encoding="utf-8"?>
xmlns:aapt="http://schemas.android.com/aapt" <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
android:width="108dp" <!-- Inset the bitmap to avoid clipping on circular masks -->
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item <item
android:color="#44000000" android:top="18dp"
android:offset="0.0" /> android:bottom="18dp"
<item android:left="18dp"
android:color="#00000000" android:right="18dp">
android:offset="1.0" /> <bitmap
</gradient> android:src="@drawable/ic_launcher"
</aapt:attr> android:gravity="center" />
</path> </item>
<path </layer-list>
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@android:color/transparent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@android:color/transparent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@@ -17,6 +17,7 @@ activityCompose = "1.9.2"
lifecycleRuntimeCompose = "2.8.6" lifecycleRuntimeCompose = "2.8.6"
lifecycleViewmodelCompose = "2.8.6" lifecycleViewmodelCompose = "2.8.6"
datastore = "1.1.1" datastore = "1.1.1"
serialization = "1.7.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -40,9 +41,11 @@ androidx-material-icons-extended = { group = "androidx.compose.material", name =
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
ktorx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }