0.1.0
This commit is contained in:
@@ -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
BIN
app/release/app-release.aab
Normal file
Binary file not shown.
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user