diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4856d3a..1669dc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/release/app-release.aab b/app/release/app-release.aab new file mode 100644 index 0000000..7b0e2e2 Binary files /dev/null and b/app/release/app-release.aab differ diff --git a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt index 935f96d..4f6abb7 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt @@ -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(null) } var showSettings by remember { mutableStateOf(false) } + var activeMatchId by remember { mutableStateOf(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, 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, + 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") } + } + } + } +} + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt index 22d8504..4ca6588 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt @@ -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() } @@ -47,6 +58,8 @@ fun GameScreen( val energyTotals = remember { mutableStateMapOf() } val experienceTotals = remember { mutableStateMapOf() } val commanderDamages = remember { mutableStateMapOf>() } + val eliminated = remember { mutableStateMapOf() } + val gameLocked = remember { mutableStateMapOf() } // 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 = 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 = 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, 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) } } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt index 29a2289..fd5df6e 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt @@ -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() } 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, diff --git a/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt index eee5fdb..e1351e4 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt @@ -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 = context.dataStore.data.map { prefs -> @@ -33,6 +50,21 @@ object AppSettingsRepository { } } } + + // Very small JSON blob to persist match history + fun readMatchHistory(context: Context): Flow> = + context.dataStore.data.map { prefs -> + prefs[MATCH_HISTORY]?.let { + runCatching { Json.decodeFromString>(it) }.getOrDefault(emptyList()) + } ?: emptyList() + } + + suspend fun writeMatchHistory(context: Context, history: List) { + val json = Json.encodeToString(history) + context.dataStore.edit { prefs -> + prefs[MATCH_HISTORY] = json + } + } } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt index f4a3979..59c3a4d 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt @@ -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> = + AppSettingsRepository.readMatchHistory(application).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + fun saveHistory(history: List) { + viewModelScope.launch { + AppSettingsRepository.writeMatchHistory(getApplication(), history) + } + } } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt b/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt index 227fa07..e7acb7b 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt @@ -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 + val commanderDamages: Map, + val scooped: Boolean = false ) @Immutable +@Serializable data class GameState( val players: List, val startingLife: Int, diff --git a/app/src/main/res/drawable-hdpi/ic_launcher.png b/app/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..e7c0793 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..971b524 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..d363502 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..909ab0a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f09531b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..7ff4389 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,15 @@ - - - - - - - - - - - \ No newline at end of file + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755..b903f5d 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755..b903f5d 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96e4247..f1d195c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }