8 Commits
0.1.1 ... 1.1.0

13 changed files with 581 additions and 285 deletions

6
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Firebase Crashlytics" />
</component>
</project>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -1,2 +1,18 @@
# MagicCounter # MagicCounter
This is a FOSS Android app meant to allow MTG Commander players to keep track of player health and commander damage. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support.
## Download
You have two options:
1. Download the latest APK from the Released page
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.magiccounter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FMagicCounter%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22MagicCounter%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
## Requirements
- Android 15+
## Contribution
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.

View File

@@ -7,21 +7,21 @@ plugins {
android { android {
namespace = "com.atridad.magiccounter" namespace = "com.atridad.magiccounter"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.magiccounter" applicationId = "com.atridad.magiccounter"
minSdk = 35 minSdk = 35
targetSdk = 35 targetSdk = 36
versionCode = 1 versionCode = 2
versionName = "0.1.1" versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"

View File

@@ -1,34 +1,42 @@
package com.atridad.magiccounter.ui package com.atridad.magiccounter.ui
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.History 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.PlayArrow
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.screens.GameScreen import com.atridad.magiccounter.ui.screens.GameScreen
import com.atridad.magiccounter.ui.screens.SetupScreen import com.atridad.magiccounter.ui.screens.SetupScreen
@@ -38,58 +46,88 @@ import com.atridad.magiccounter.ui.settings.AppSettingsViewModel
import com.atridad.magiccounter.ui.settings.ThemeMode import com.atridad.magiccounter.ui.settings.ThemeMode
import com.atridad.magiccounter.ui.settings.MatchRecord import com.atridad.magiccounter.ui.settings.MatchRecord
import com.atridad.magiccounter.ui.theme.MagicCounterTheme import com.atridad.magiccounter.ui.theme.MagicCounterTheme
import androidx.compose.material3.ModalBottomSheet import androidx.activity.compose.BackHandler
import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider
// no-op import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.Spacer
// Top-level navigation destinations private sealed class Screen {
private enum class Screen { Home, Setup, Game } data object Home : Screen()
data object Setup : Screen()
data object Settings : Screen()
data class Game(val matchId: String, val bootState: GameState? = null) : Screen()
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MagicCounterApp() { fun MagicCounterApp() {
var gameState by remember { mutableStateOf<GameState?>(null) } val screenStack: SnapshotStateList<Screen> = remember { mutableStateListOf(Screen.Home) }
var showSettings by remember { mutableStateOf(false) }
var activeMatchId by remember { mutableStateOf<String?>(null) }
var screen by remember { mutableStateOf(Screen.Home) }
val settingsVm: AppSettingsViewModel = viewModel() val settingsVm: AppSettingsViewModel = viewModel()
val theme = settingsVm.themeMode.collectAsState() val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState() val historyState = settingsVm.matchHistory.collectAsState()
// State for clear history confirmation
var showClearConfirm by remember { mutableStateOf(false) }
MagicCounterTheme(themeMode = theme.value) { MagicCounterTheme(themeMode = theme.value) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) }, title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
navigationIcon = {
if (screenStack.size > 1) {
IconButton(onClick = { screenStack.removeLast() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = { actions = {
if (screen != Screen.Home) { // Show Clear History icon only on home screen
IconButton(onClick = { screen = Screen.Home }) { if (screenStack.last() is Screen.Home) {
Icon(Icons.Default.Home, contentDescription = "Home") IconButton(onClick = { showClearConfirm = true }) {
Icon(Icons.Default.Clear, contentDescription = "Clear history")
} }
} }
IconButton(onClick = { showSettings = true }) { IconButton(onClick = { screenStack.add(Screen.Settings) }) {
Icon(Icons.Default.Settings, contentDescription = "App settings") Icon(Icons.Default.Settings, contentDescription = "App settings")
} }
} }
) )
},
floatingActionButton = {
// Show FAB only on home screen when no active game is running
val currentScreen = screenStack.last()
if (currentScreen is Screen.Home && historyState.value.none { it.ongoing }) {
FloatingActionButton(
onClick = { screenStack.add(Screen.Setup) }
) {
Icon(Icons.Default.Add, contentDescription = "New game")
}
}
} }
) { paddingValues -> ) { paddingValues ->
when (screen) { val currentScreen = screenStack.last()
Screen.Home -> HomeScreen( BackHandler(enabled = screenStack.size > 1) { screenStack.removeLast() }
when (currentScreen) {
is Screen.Home -> HomeScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
history = historyState.value, history = historyState.value,
onNewGame = { screen = Screen.Setup }, onResume = { record -> screenStack.add(Screen.Game(record.id)) },
onResume = { record -> onDelete = { id ->
activeMatchId = record.id val newList = historyState.value.filterNot { it.id == id }
gameState = record.state settingsVm.saveHistory(newList)
screen = Screen.Game },
showClearConfirm = showClearConfirm,
onClearConfirm = { showClearConfirm = false },
onClear = {
settingsVm.saveHistory(emptyList())
showClearConfirm = false
} }
) )
Screen.Setup -> SetupScreen( is Screen.Setup -> SetupScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
@@ -108,33 +146,35 @@ fun MagicCounterApp() {
) )
val updated = historyState.value.toMutableList().apply { add(0, record) } val updated = historyState.value.toMutableList().apply { add(0, record) }
settingsVm.saveHistory(updated) settingsVm.saveHistory(updated)
activeMatchId = newId // Replace Setup with the new Game screen (pop then push)
gameState = state if (screenStack.isNotEmpty()) screenStack.removeLast()
screen = Screen.Game screenStack.add(Screen.Game(newId, state))
} }
) )
Screen.Game -> GameScreen( is Screen.Game -> {
val id = currentScreen.matchId
val bootState = currentScreen.bootState
val record = historyState.value.firstOrNull { it.id == id }
val stateForGame = record?.state ?: bootState
if (stateForGame == null) {
screenStack.removeLast()
} else {
GameScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
state = gameState!!, state = stateForGame,
onEnd = { screen = Screen.Home },
onProgress = { updated -> onProgress = { updated ->
val current = historyState.value val current = historyState.value
val id = activeMatchId
if (id != null) {
val idx = current.indexOfFirst { it.id == id } val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) { if (idx >= 0) {
val updatedRec = current[idx].copy(state = updated, lastUpdatedEpochMs = System.currentTimeMillis()) val updatedRec = current[idx].copy(state = updated, lastUpdatedEpochMs = System.currentTimeMillis())
val newList = current.toMutableList().apply { set(idx, updatedRec) } val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList) settingsVm.saveHistory(newList)
} }
}
}, },
onWinner = { winnerId, finalState -> onWinner = { winnerId, finalState ->
val current = historyState.value val current = historyState.value
val id = activeMatchId
if (id != null) {
val idx = current.indexOfFirst { it.id == id } val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) { if (idx >= 0) {
val updatedRec = current[idx].copy( val updatedRec = current[idx].copy(
@@ -147,31 +187,33 @@ fun MagicCounterApp() {
settingsVm.saveHistory(newList) settingsVm.saveHistory(newList)
} }
} }
}
) )
} }
if (showSettings) { }
ModalBottomSheet(onDismissRequest = { showSettings = false }) { is Screen.Settings -> SettingsContent(
SettingsSheet( modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
current = theme.value, current = theme.value,
onSelect = { settingsVm.setTheme(it) } onSelect = { settingsVm.setTheme(it) }
) )
} }
}
}
}
}
@Composable // Global clear history confirmation dialog
private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) { if (showClearConfirm) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { AlertDialog(
Text("Theme") onDismissRequest = { showClearConfirm = false },
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { title = { Text("Clear history?") },
ThemeMode.values().forEach { mode -> text = { Text("This will remove all matches. This action cannot be undone.") },
FilterChip( confirmButton = {
selected = current == mode, TextButton(onClick = {
onClick = { onSelect(mode) }, settingsVm.saveHistory(emptyList())
label = { Text(mode.name) } showClearConfirm = false
}) { Text("Clear") }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") }
}
) )
} }
} }
@@ -179,19 +221,34 @@ private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
} }
@Composable @Composable
private fun HistorySheet(history: List<MatchRecord>, onResume: (MatchRecord) -> Unit) { private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
Text("Match history") modifier = modifier.padding(24.dp),
history.forEach { rec -> verticalArrangement = Arrangement.spacedBy(24.dp)
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) { ) {
Column { Text(
Text(rec.name) "Appearance",
val status = if (rec.ongoing) "Ongoing" else "Finished" style = MaterialTheme.typography.headlineSmall,
Text("$status • Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}") color = MaterialTheme.colorScheme.primary
rec.winnerPlayerId?.let { Text("Winner: Player ${it + 1}") } )
}
IconButton(onClick = { onResume(rec) }) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Resume") Text(
"Theme",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
ThemeMode.entries.forEach { mode ->
androidx.compose.material3.FilterChip(
selected = current == mode,
onClick = { onSelect(mode) },
label = { Text(mode.name) },
modifier = Modifier.weight(1f)
)
} }
} }
} }
@@ -202,33 +259,203 @@ private fun HistorySheet(history: List<MatchRecord>, onResume: (MatchRecord) ->
private fun HomeScreen( private fun HomeScreen(
modifier: Modifier, modifier: Modifier,
history: List<MatchRecord>, history: List<MatchRecord>,
onNewGame: () -> Unit, onResume: (MatchRecord) -> Unit,
onResume: (MatchRecord) -> Unit onDelete: (String) -> Unit,
showClearConfirm: Boolean,
onClearConfirm: () -> Unit,
onClear: () -> Unit
) { ) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNewGame) { Text("New game") } var pendingDeleteId by remember { mutableStateOf<String?>(null) }
Text("Ongoing") // Only show action buttons if there are ongoing games
history.filter { it.ongoing }.forEach { rec -> if (history.any { it.ongoing }) {
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) { Column(
Column { modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
Text(rec.name) verticalArrangement = Arrangement.spacedBy(12.dp)
Text("Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}") ) {
Text(
"Quick Actions",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
androidx.compose.material3.OutlinedButton(
onClick = { onResume(history.first { it.ongoing }) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.padding(4.dp))
Text("Resume Game")
}
androidx.compose.material3.OutlinedButton(
onClick = { pendingDeleteId = history.first { it.ongoing }.id },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Spacer(modifier = Modifier.padding(4.dp))
Text("Delete Game")
}
} }
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") }
} }
} }
Text("Finished") if (history.isEmpty()) {
history.filter { !it.ongoing }.forEach { rec -> androidx.compose.foundation.layout.Box(
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) { modifier = Modifier.fillMaxSize(),
Column { contentAlignment = Alignment.Center
Text(rec.name) ) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.History,
contentDescription = "Empty history",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"No games yet",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Start your first Magic: The Gathering game to begin tracking life totals and more.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item {
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 12.dp)) {
Text(
"Ongoing Games",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
}
items(history.filter { it.ongoing }, key = { it.id }) { rec ->
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
rec.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = { onResume(rec) }) {
Icon(Icons.Default.PlayArrow, contentDescription = "Resume game")
}
IconButton(onClick = { pendingDeleteId = rec.id }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
}
item {
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp)) {
Text(
"Finished Games",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
}
items(history.filter { !it.ongoing }, key = { it.id }) { rec ->
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
rec.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: "" val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner") Text(
"Finished • $winner",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} }
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = { onResume(rec) }) {
Icon(Icons.Default.Visibility, contentDescription = "View match")
} }
IconButton(onClick = { pendingDeleteId = rec.id }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
}
}
}
// Confirm delete dialog
val pending = history.firstOrNull { it.id == pendingDeleteId }
if (pendingDeleteId != null && pending != null) {
AlertDialog(
onDismissRequest = { pendingDeleteId = null },
title = { Text("Delete match?") },
text = { Text("Are you sure you want to delete \"${pending.name}\"?") },
confirmButton = {
TextButton(onClick = {
onDelete(pendingDeleteId!!)
pendingDeleteId = null
}) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { pendingDeleteId = null }) { Text("Cancel") } }
)
}
// Confirm clear dialog
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { onClearConfirm() },
title = { Text("Clear history?") },
text = { Text("This will remove all matches. This action cannot be undone.") },
confirmButton = {
TextButton(onClick = { onClear() }) { Text("Clear") }
},
dismissButton = { TextButton(onClick = { onClearConfirm() }) { Text("Cancel") } }
)
} }
} }
} }

View File

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

View File

@@ -1,7 +1,5 @@
package com.atridad.magiccounter.ui.screens package com.atridad.magiccounter.ui.screens
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -11,8 +9,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,7 +33,6 @@ import com.atridad.magiccounter.ui.state.PlayerState
import com.atridad.magiccounter.ui.state.defaultPlayerName import com.atridad.magiccounter.ui.state.defaultPlayerName
import kotlin.math.roundToInt import kotlin.math.roundToInt
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
fun SetupScreen( fun SetupScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -41,8 +41,6 @@ fun SetupScreen(
var playerCount by remember { mutableIntStateOf(4) } var playerCount by remember { mutableIntStateOf(4) }
var startingLife by remember { mutableIntStateOf(40) } var startingLife by remember { mutableIntStateOf(40) }
var trackPoison by remember { mutableStateOf(true) } var trackPoison by remember { mutableStateOf(true) }
var trackEnergy by remember { mutableStateOf(false) }
var trackExperience by remember { mutableStateOf(false) }
var trackCommander by remember { mutableStateOf(true) } var trackCommander by remember { mutableStateOf(true) }
var matchName by remember { mutableStateOf("") } var matchName by remember { mutableStateOf("") }
@@ -54,11 +52,24 @@ fun SetupScreen(
Column( Column(
modifier = modifier modifier = modifier
.padding(16.dp) .padding(24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
Text("Starting life: $startingLife") // Game Settings Section
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Game Settings",
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Starting life: $startingLife",
style = androidx.compose.material3.MaterialTheme.typography.titleMedium
)
Slider( Slider(
value = startingLife.toFloat(), value = startingLife.toFloat(),
onValueChange = { onValueChange = {
@@ -68,72 +79,111 @@ fun SetupScreen(
valueRange = 10f..40f, valueRange = 10f..40f,
steps = 5 steps = 5
) )
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text("Players: $playerCount")
} }
Slider(value = playerCount.toFloat(), onValueChange = { playerCount = it.toInt() }, valueRange = 2f..8f, steps = 6)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Players: $playerCount",
style = androidx.compose.material3.MaterialTheme.typography.titleMedium
)
Slider(
value = playerCount.toFloat(),
onValueChange = { playerCount = it.toInt() },
valueRange = 2f..8f,
steps = 6
)
}
}
}
// Game Options Section
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Game Options",
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackCommander, onCheckedChange = { trackCommander = it }) Checkbox(checked = trackCommander, onCheckedChange = { trackCommander = it })
Text("Track commander damage") Text(
"Track commander damage",
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge
)
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackPoison, onCheckedChange = { trackPoison = it }) Checkbox(checked = trackPoison, onCheckedChange = { trackPoison = it })
Text("Track poison") Text(
"Track poison",
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge
)
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackEnergy, onCheckedChange = { trackEnergy = it })
Text("Track energy")
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackExperience, onCheckedChange = { trackExperience = it })
Text("Track experience")
} }
// Match Details Section
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Match Details",
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = matchName, value = matchName,
onValueChange = { matchName = it }, onValueChange = { matchName = it },
label = { Text("Match name") } label = { Text("Match name") },
singleLine = true
) )
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
names.forEachIndexed { index, value -> names.forEachIndexed { index, value ->
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = value, value = value,
onValueChange = { names[index] = it }, onValueChange = { names[index] = it },
label = { Text("Player ${index + 1} name") } label = { Text("Player ${index + 1} name") },
singleLine = true
) )
} }
} }
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
// Note: The actual start button is now handled by a FAB in the parent Scaffold
// This button is kept for accessibility and as a fallback
val canStart = matchName.isNotBlank()
Button(
onClick = {
val players = names.mapIndexed { index, name -> val players = names.mapIndexed { index, name ->
PlayerState( PlayerState(
id = index, id = index,
name = name.ifBlank { defaultPlayerName(index) }, name = name.ifBlank { defaultPlayerName(index) },
life = startingLife, life = startingLife,
poison = 0, poison = 0,
energy = 0,
experience = 0,
commanderDamages = emptyMap() commanderDamages = emptyMap()
) )
} }
onStart( onStart(
matchName.ifBlank { "Game ${System.currentTimeMillis()}" }, matchName,
GameState( GameState(
players = players, players = players,
startingLife = startingLife, startingLife = startingLife,
trackPoison = trackPoison, trackPoison = trackPoison,
trackEnergy = trackEnergy,
trackExperience = trackExperience,
trackCommanderDamage = trackCommander trackCommanderDamage = trackCommander
) )
) )
}) { },
Text("Start game") enabled = canStart,
modifier = Modifier.fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp)
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.padding(8.dp))
Text("Start Game", style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.9.0" agp = "8.12.1"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"

View File

@@ -1,6 +1,6 @@
#Sat Aug 09 23:53:05 MDT 2025 #Sat Aug 09 23:53:05 MDT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists