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.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -13,7 +14,7 @@ android {
minSdk = 35
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -51,6 +52,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.ktorx.serialization.json)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.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.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.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -31,19 +36,27 @@ import com.atridad.magiccounter.ui.state.GameState
import androidx.lifecycle.viewmodel.compose.viewModel
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
// Top-level navigation destinations
private enum class Screen { Home, Setup, Game }
@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 settingsVm: AppSettingsViewModel = viewModel()
val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState()
MagicCounterTheme(themeMode = theme.value) {
Scaffold(
@@ -51,9 +64,9 @@ fun MagicCounterApp() {
TopAppBar(
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
actions = {
if (gameState != null) {
IconButton(onClick = { gameState = null }) {
Icon(Icons.Default.Refresh, contentDescription = "New game")
if (screen != Screen.Home) {
IconButton(onClick = { screen = Screen.Home }) {
Icon(Icons.Default.Home, contentDescription = "Home")
}
}
IconButton(onClick = { showSettings = true }) {
@@ -63,20 +76,78 @@ fun MagicCounterApp() {
)
}
) { paddingValues ->
if (gameState == null) {
SetupScreen(
when (screen) {
Screen.Home -> HomeScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
onStart = { state -> gameState = state }
history = historyState.value,
onNewGame = { screen = Screen.Setup },
onResume = { record ->
activeMatchId = record.id
gameState = record.state
screen = Screen.Game
}
)
} else {
GameScreen(
Screen.Setup -> SetupScreen(
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
.padding(paddingValues)
.fillMaxSize(),
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) {
@@ -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
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.BorderStroke
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.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.BorderStroke
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -21,25 +26,31 @@ import androidx.compose.material3.FilledTonalIconButton
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.graphicsLayer
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 java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GameScreen(
modifier: Modifier = Modifier,
state: GameState,
onEnd: () -> Unit
onEnd: () -> Unit,
onProgress: ((GameState) -> Unit)? = null,
onWinner: ((Int, GameState) -> Unit)? = null
) {
// Local editable state per player
val lifeTotals = remember { mutableStateMapOf<Int, Int>() }
@@ -47,6 +58,8 @@ fun GameScreen(
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
// Initialize
state.players.forEach { p ->
@@ -55,43 +68,113 @@ fun GameScreen(
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)
}
// 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
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)) {
// 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(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.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 perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap()
PlayerCard(
player = player,
opponents = state.players.map { it.id }.filter { it != player.id },
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,
onPoisonChange = { poisonTotals[player.id] = it },
onPoisonChange = {
if (gameLocked["locked"] != true) {
poisonTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
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,
onExperienceChange = { experienceTotals[player.id] = it },
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 ->
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
newMap[fromId] = dmg
commanderDamages[player.id] = newMap
if (gameLocked["locked"] != true) {
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
newMap[fromId] = dmg
commanderDamages[player.id] = newMap
checkElimination(player.id)
onProgress?.invoke(snapshotState())
}
},
rotation = 0f,
accentColor = accent
accentColor = accent,
isEliminated = eliminated[player.id] == true,
onScoop = {
if (gameLocked["locked"] != true) {
eliminated[player.id] = true
onProgress?.invoke(snapshotState())
}
}
)
}
}
@@ -126,35 +209,59 @@ private fun PlayerCard(
commanderDamages: Map<Int, Int>,
onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit,
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(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.graphicsLayer { rotationZ = rotation },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
colors = CardDefaults.cardColors(containerColor = containerColor.value),
border = BorderStroke(2.dp, accentColor)
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(player.name, style = MaterialTheme.typography.titleMedium, color = accentColor)
BigLifeRow(value = life, onChange = onLifeChange)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange)
if (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange)
if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange)
if (trackCommanderDamage) {
Divider()
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) })
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) {
Icon(Icons.Default.Flag, contentDescription = "Scoop")
Text("Scoop")
}
}
BigLifeRow(value = life, onChange = onLifeChange, enabled = !isEliminated)
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 (trackCommanderDamage) {
Divider()
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)
}
}
}
}
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,
value: Int,
onChange: (Int) -> Unit,
emphasized: Boolean = false
emphasized: Boolean = false,
enabled: Boolean = true
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -177,20 +285,20 @@ private fun ChipRow(
) {
Text(label)
Row(verticalAlignment = Alignment.CenterVertically) {
CounterIconButton(Icons.Default.Remove, "decrement") { onChange(value - 1) }
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
)
CounterIconButton(Icons.Default.Add, "increment") { onChange(value + 1) }
CounterIconButton(Icons.Default.Add, "increment", enabled = enabled) { onChange(value + 1) }
}
}
}
@Composable
private fun BigLifeRow(value: Int, onChange: (Int) -> Unit) {
private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = true) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -198,21 +306,21 @@ private fun BigLifeRow(value: Int, onChange: (Int) -> Unit) {
) {
Text("Life", style = MaterialTheme.typography.titleMedium)
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 = value.toString(),
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center
)
CounterIconButton(Icons.Default.Add, "increment life") { onChange(value + 1) }
CounterIconButton(Icons.Default.Add, "increment life", enabled = enabled) { onChange(value + 1) }
}
}
}
@Composable
private fun CounterIconButton(icon: ImageVector, contentDescription: String, onClick: () -> Unit) {
FilledTonalIconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
private fun CounterIconButton(icon: ImageVector, contentDescription: String, enabled: Boolean = true, onClick: () -> Unit) {
FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
Icon(icon, contentDescription = contentDescription)
}
}

View File

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

View File

@@ -4,9 +4,25 @@ 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
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 }
@@ -14,6 +30,7 @@ private val Context.dataStore by preferencesDataStore(name = "app_settings")
object AppSettingsRepository {
private val THEME_MODE = intPreferencesKey("theme_mode")
private val MATCH_HISTORY = stringPreferencesKey("match_history_json")
fun themeMode(context: Context): Flow<ThemeMode> =
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 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
@@ -21,6 +22,19 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
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
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
data class PlayerState(
val id: Int,
val name: String,
@@ -16,10 +21,12 @@ data class PlayerState(
val poison: Int,
val energy: Int,
val experience: Int,
val commanderDamages: Map<Int, Int>
val commanderDamages: Map<Int, Int>,
val scooped: Boolean = false
)
@Immutable
@Serializable
data class GameState(
val players: List<PlayerState>,
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"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
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
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
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>
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Inset the bitmap to avoid clipping on circular masks -->
<item
android:top="18dp"
android:bottom="18dp"
android:left="18dp"
android:right="18dp">
<bitmap
android:src="@drawable/ic_launcher"
android:gravity="center" />
</item>
</layer-list>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -17,6 +17,7 @@ activityCompose = "1.9.2"
lifecycleRuntimeCompose = "2.8.6"
lifecycleViewmodelCompose = "2.8.6"
datastore = "1.1.1"
serialization = "1.7.3"
[libraries]
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-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", 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" }