Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
63c3fc86a0
|
|||
|
98ca6e676c
|
|||
|
54d9239dfe
|
|||
|
d04e4d2f58
|
|||
|
3c498c4b18
|
|||
|
98bf835815
|
|||
|
e99db5fd60
|
|||
|
35228d9374
|
|||
|
485ce337c7
|
|||
|
7e4c63aa00
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
release/
|
||||
6
.idea/appInsightsSettings.xml
generated
Normal file
6
.idea/appInsightsSettings.xml
generated
Normal 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
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
16
README.md
16
README.md
@@ -1,2 +1,18 @@
|
||||
# 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.
|
||||
@@ -7,21 +7,21 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.atridad.magiccounter"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.atridad.magiccounter"
|
||||
minSdk = 35
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
|
||||
Binary file not shown.
@@ -1,34 +1,42 @@
|
||||
package com.atridad.magiccounter.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.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.AlertDialog
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.magiccounter.ui.screens.GameScreen
|
||||
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.MatchRecord
|
||||
import com.atridad.magiccounter.ui.theme.MagicCounterTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.FilterChip
|
||||
// no-op
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
||||
// Top-level navigation destinations
|
||||
private enum class Screen { Home, Setup, Game }
|
||||
private sealed class Screen {
|
||||
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)
|
||||
@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 screenStack: SnapshotStateList<Screen> = remember { mutableStateListOf(Screen.Home) }
|
||||
val settingsVm: AppSettingsViewModel = viewModel()
|
||||
val theme = settingsVm.themeMode.collectAsState()
|
||||
val historyState = settingsVm.matchHistory.collectAsState()
|
||||
|
||||
// State for clear history confirmation
|
||||
var showClearConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
MagicCounterTheme(themeMode = theme.value) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
|
||||
actions = {
|
||||
if (screen != Screen.Home) {
|
||||
IconButton(onClick = { screen = Screen.Home }) {
|
||||
Icon(Icons.Default.Home, contentDescription = "Home")
|
||||
navigationIcon = {
|
||||
if (screenStack.size > 1) {
|
||||
IconButton(onClick = { screenStack.removeLast() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
},
|
||||
actions = {
|
||||
// Show Clear History icon only on home screen
|
||||
if (screenStack.last() is Screen.Home) {
|
||||
IconButton(onClick = { showClearConfirm = true }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear history")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { screenStack.add(Screen.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 ->
|
||||
when (screen) {
|
||||
Screen.Home -> HomeScreen(
|
||||
val currentScreen = screenStack.last()
|
||||
BackHandler(enabled = screenStack.size > 1) { screenStack.removeLast() }
|
||||
when (currentScreen) {
|
||||
is Screen.Home -> HomeScreen(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
history = historyState.value,
|
||||
onNewGame = { screen = Screen.Setup },
|
||||
onResume = { record ->
|
||||
activeMatchId = record.id
|
||||
gameState = record.state
|
||||
screen = Screen.Game
|
||||
onResume = { record -> screenStack.add(Screen.Game(record.id)) },
|
||||
onDelete = { id ->
|
||||
val newList = historyState.value.filterNot { it.id == id }
|
||||
settingsVm.saveHistory(newList)
|
||||
},
|
||||
showClearConfirm = showClearConfirm,
|
||||
onClearConfirm = { showClearConfirm = false },
|
||||
onClear = {
|
||||
settingsVm.saveHistory(emptyList())
|
||||
showClearConfirm = false
|
||||
}
|
||||
)
|
||||
Screen.Setup -> SetupScreen(
|
||||
is Screen.Setup -> SetupScreen(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
@@ -108,90 +146,109 @@ fun MagicCounterApp() {
|
||||
)
|
||||
val updated = historyState.value.toMutableList().apply { add(0, record) }
|
||||
settingsVm.saveHistory(updated)
|
||||
activeMatchId = newId
|
||||
gameState = state
|
||||
screen = Screen.Game
|
||||
// Replace Setup with the new Game screen (pop then push)
|
||||
if (screenStack.isNotEmpty()) screenStack.removeLast()
|
||||
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
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
state = stateForGame,
|
||||
onProgress = { updated ->
|
||||
val current = historyState.value
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is Screen.Settings -> SettingsContent(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
state = gameState!!,
|
||||
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)
|
||||
}
|
||||
}
|
||||
current = theme.value,
|
||||
onSelect = { settingsVm.setTheme(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// Global clear history confirmation dialog
|
||||
if (showClearConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearConfirm = false },
|
||||
title = { Text("Clear history?") },
|
||||
text = { Text("This will remove all matches. This action cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
settingsVm.saveHistory(emptyList())
|
||||
showClearConfirm = false
|
||||
}) { Text("Clear") }
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showSettings) {
|
||||
ModalBottomSheet(onDismissRequest = { showSettings = false }) {
|
||||
SettingsSheet(
|
||||
current = theme.value,
|
||||
onSelect = { settingsVm.setTheme(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Theme")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ThemeMode.values().forEach { mode ->
|
||||
FilterChip(
|
||||
selected = current == mode,
|
||||
onClick = { onSelect(mode) },
|
||||
label = { Text(mode.name) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text(
|
||||
"Appearance",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
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(
|
||||
modifier: Modifier,
|
||||
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)) {
|
||||
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))}")
|
||||
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Only show action buttons if there are ongoing games
|
||||
if (history.any { it.ongoing }) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
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")
|
||||
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")
|
||||
if (history.isEmpty()) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") }
|
||||
}
|
||||
} 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}" } ?: ""
|
||||
Text(
|
||||
"Finished • $winner",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
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") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,15 @@ import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -41,32 +42,35 @@ 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
|
||||
/**
|
||||
* 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(
|
||||
modifier: Modifier = Modifier,
|
||||
state: GameState,
|
||||
onEnd: () -> Unit,
|
||||
onProgress: ((GameState) -> Unit)? = null,
|
||||
onWinner: ((Int, GameState) -> Unit)? = null
|
||||
) {
|
||||
// Local editable state per player
|
||||
val lifeTotals = 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 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
|
||||
state.players.forEach { p ->
|
||||
lifeTotals.putIfAbsent(p.id, p.life)
|
||||
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())
|
||||
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(
|
||||
players = state.players.map { p ->
|
||||
PlayerState(
|
||||
@@ -93,8 +94,6 @@ fun GameScreen(
|
||||
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
|
||||
)
|
||||
@@ -104,12 +103,13 @@ fun GameScreen(
|
||||
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 currentWinnerId: Int? = if (aliveCount == 1) {
|
||||
state.players.first { eliminated[it.id] != true }.id
|
||||
} else null
|
||||
|
||||
if (currentWinnerId != null && !gameLocked.value) {
|
||||
gameLocked.value = true
|
||||
onWinner?.invoke(currentWinnerId, snapshotState())
|
||||
}
|
||||
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 },
|
||||
life = lifeTotals[player.id] ?: state.startingLife,
|
||||
onLifeChange = { new ->
|
||||
if (gameLocked["locked"] == true) return@PlayerCard
|
||||
if (gameLocked.value) return@PlayerCard
|
||||
lifeTotals[player.id] = new
|
||||
checkElimination(player.id)
|
||||
onProgress?.invoke(snapshotState())
|
||||
},
|
||||
poison = poisonTotals[player.id] ?: 0,
|
||||
onPoisonChange = {
|
||||
if (gameLocked["locked"] != true) {
|
||||
if (!gameLocked.value) {
|
||||
poisonTotals[player.id] = it
|
||||
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,
|
||||
trackEnergy = state.trackEnergy,
|
||||
trackExperience = state.trackExperience,
|
||||
trackCommanderDamage = state.trackCommanderDamage,
|
||||
commanderDamages = perPlayerCommander,
|
||||
onCommanderDamageChange = { fromId, dmg ->
|
||||
if (gameLocked["locked"] != true) {
|
||||
if (!gameLocked.value) {
|
||||
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
|
||||
newMap[fromId] = dmg
|
||||
commanderDamages[player.id] = newMap
|
||||
@@ -169,8 +153,9 @@ fun GameScreen(
|
||||
rotation = 0f,
|
||||
accentColor = accent,
|
||||
isEliminated = eliminated[player.id] == true,
|
||||
isWinner = currentWinnerId == player.id,
|
||||
onScoop = {
|
||||
if (gameLocked["locked"] != true) {
|
||||
if (!gameLocked.value) {
|
||||
eliminated[player.id] = true
|
||||
onProgress?.invoke(snapshotState())
|
||||
}
|
||||
@@ -190,6 +175,22 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color
|
||||
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
|
||||
private fun PlayerCard(
|
||||
player: PlayerState,
|
||||
@@ -198,24 +199,20 @@ private fun PlayerCard(
|
||||
onLifeChange: (Int) -> Unit,
|
||||
poison: Int,
|
||||
onPoisonChange: (Int) -> Unit,
|
||||
energy: Int,
|
||||
onEnergyChange: (Int) -> Unit,
|
||||
experience: Int,
|
||||
onExperienceChange: (Int) -> Unit,
|
||||
trackPoison: Boolean,
|
||||
trackEnergy: Boolean,
|
||||
trackExperience: Boolean,
|
||||
trackCommanderDamage: Boolean,
|
||||
commanderDamages: Map<Int, Int>,
|
||||
onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit,
|
||||
rotation: Float,
|
||||
accentColor: Color,
|
||||
isEliminated: Boolean,
|
||||
isWinner: 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")
|
||||
val controlsEnabled = !isEliminated && !isWinner
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -224,31 +221,29 @@ private fun PlayerCard(
|
||||
.graphicsLayer { rotationZ = rotation },
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
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()) {
|
||||
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) {
|
||||
Text(player.name, style = MaterialTheme.typography.titleMedium, color = if (isWinner) Color(0xFF2E7D32) else accentColor)
|
||||
TextButton(onClick = onScoop, enabled = controlsEnabled) {
|
||||
Icon(Icons.Default.Flag, contentDescription = "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 (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange, enabled = !isEliminated)
|
||||
if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange, enabled = !isEliminated)
|
||||
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = controlsEnabled)
|
||||
|
||||
if (trackCommanderDamage) {
|
||||
Divider()
|
||||
HorizontalDivider()
|
||||
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)
|
||||
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
|
||||
private fun ChipRow(
|
||||
label: String,
|
||||
value: Int,
|
||||
onChange: (Int) -> Unit,
|
||||
emphasized: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
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
|
||||
private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = true) {
|
||||
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
|
||||
private fun CounterIconButton(icon: ImageVector, contentDescription: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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.Column
|
||||
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.rememberScrollState
|
||||
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.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
@@ -30,8 +31,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.atridad.magiccounter.ui.state.GameState
|
||||
import com.atridad.magiccounter.ui.state.PlayerState
|
||||
import com.atridad.magiccounter.ui.state.defaultPlayerName
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@Composable
|
||||
fun SetupScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -40,8 +41,6 @@ fun SetupScreen(
|
||||
var playerCount by remember { mutableIntStateOf(4) }
|
||||
var startingLife by remember { mutableIntStateOf(40) }
|
||||
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 matchName by remember { mutableStateOf("") }
|
||||
|
||||
@@ -53,78 +52,138 @@ fun SetupScreen(
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text("Starting life: $startingLife")
|
||||
Slider(value = startingLife.toFloat(), onValueChange = { startingLife = it.toInt() }, valueRange = 20f..60f, steps = 20)
|
||||
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)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = trackCommander, onCheckedChange = { trackCommander = it })
|
||||
Text("Track commander damage")
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = trackPoison, onCheckedChange = { trackPoison = it })
|
||||
Text("Track poison")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = matchName,
|
||||
onValueChange = { matchName = it },
|
||||
label = { Text("Match name") }
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
names.forEachIndexed { index, value ->
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = value,
|
||||
onValueChange = { names[index] = it },
|
||||
label = { Text("Player ${index + 1} name") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
val players = names.mapIndexed { index, name ->
|
||||
PlayerState(
|
||||
id = index,
|
||||
name = name.ifBlank { defaultPlayerName(index) },
|
||||
life = startingLife,
|
||||
poison = 0,
|
||||
energy = 0,
|
||||
experience = 0,
|
||||
commanderDamages = emptyMap()
|
||||
)
|
||||
}
|
||||
onStart(
|
||||
matchName.ifBlank { "Game ${System.currentTimeMillis()}" },
|
||||
GameState(
|
||||
players = players,
|
||||
startingLife = startingLife,
|
||||
trackPoison = trackPoison,
|
||||
trackEnergy = trackEnergy,
|
||||
trackExperience = trackExperience,
|
||||
trackCommanderDamage = trackCommander
|
||||
)
|
||||
// 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
|
||||
)
|
||||
}) {
|
||||
Text("Start game")
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Starting life: $startingLife",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Slider(
|
||||
value = startingLife.toFloat(),
|
||||
onValueChange = {
|
||||
val snapped = ((it / 5f).roundToInt() * 5).coerceIn(10, 40)
|
||||
startingLife = snapped
|
||||
},
|
||||
valueRange = 10f..40f,
|
||||
steps = 5
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
Checkbox(checked = trackCommander, onCheckedChange = { trackCommander = it })
|
||||
Text(
|
||||
"Track commander damage",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = trackPoison, onCheckedChange = { trackPoison = it })
|
||||
Text(
|
||||
"Track poison",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = matchName,
|
||||
onValueChange = { matchName = it },
|
||||
label = { Text("Match name") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
names.forEachIndexed { index, value ->
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = value,
|
||||
onValueChange = { names[index] = it },
|
||||
label = { Text("Player ${index + 1} name") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 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 ->
|
||||
PlayerState(
|
||||
id = index,
|
||||
name = name.ifBlank { defaultPlayerName(index) },
|
||||
life = startingLife,
|
||||
poison = 0,
|
||||
commanderDamages = emptyMap()
|
||||
)
|
||||
}
|
||||
onStart(
|
||||
matchName,
|
||||
GameState(
|
||||
players = players,
|
||||
startingLife = startingLife,
|
||||
trackPoison = trackPoison,
|
||||
trackCommanderDamage = trackCommander
|
||||
)
|
||||
)
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.atridad.magiccounter.ui.settings
|
||||
|
||||
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
|
||||
@@ -29,6 +27,8 @@ enum class ThemeMode { System, Light, Dark }
|
||||
private val Context.dataStore by preferencesDataStore(name = "app_settings")
|
||||
|
||||
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 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>> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
prefs[MATCH_HISTORY]?.let {
|
||||
runCatching { Json.decodeFromString<List<MatchRecord>>(it) }.getOrDefault(emptyList())
|
||||
runCatching { json.decodeFromString<List<MatchRecord>>(it) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun writeMatchHistory(context: Context, history: List<MatchRecord>) {
|
||||
val json = Json.encodeToString(history)
|
||||
val jsonStr = json.encodeToString(history)
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[MATCH_HISTORY] = json
|
||||
prefs[MATCH_HISTORY] = jsonStr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
|
||||
@@ -2,15 +2,6 @@ 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
|
||||
@@ -19,8 +10,6 @@ data class PlayerState(
|
||||
val name: String,
|
||||
val life: Int,
|
||||
val poison: Int,
|
||||
val energy: Int,
|
||||
val experience: Int,
|
||||
val commanderDamages: Map<Int, Int>,
|
||||
val scooped: Boolean = false
|
||||
)
|
||||
@@ -31,8 +20,6 @@ data class GameState(
|
||||
val players: List<PlayerState>,
|
||||
val startingLife: Int,
|
||||
val trackPoison: Boolean,
|
||||
val trackEnergy: Boolean,
|
||||
val trackExperience: Boolean,
|
||||
val trackCommanderDamage: Boolean
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.9.0"
|
||||
agp = "8.12.1"
|
||||
kotlin = "2.0.21"
|
||||
coreKtx = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Sat Aug 09 23:53:05 MDT 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Reference in New Issue
Block a user