1.2.0 - UX Improvements

This commit is contained in:
2025-08-22 12:58:33 -06:00
parent 63c3fc86a0
commit c721b5550f
8 changed files with 563 additions and 198 deletions

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>

3
.idea/misc.xml generated
View File

@@ -1,6 +1,7 @@
<?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_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -11,10 +11,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.atridad.magiccounter" applicationId = "com.atridad.magiccounter"
minSdk = 35 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 2
versionName = "1.1.0" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -28,13 +28,22 @@ android {
) )
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures { buildFeatures {
compose = true compose = true
} }

View File

@@ -12,11 +12,8 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -30,13 +27,16 @@ 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.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf 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.runtime.snapshots.SnapshotStateList
import kotlinx.coroutines.delay
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
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
@@ -46,11 +46,10 @@ 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 com.atridad.magiccounter.ui.theme.CustomIcons
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.Spacer
private sealed class Screen { private sealed class Screen {
data object Home : Screen() data object Home : Screen()
@@ -67,8 +66,7 @@ fun MagicCounterApp() {
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(
@@ -77,18 +75,13 @@ fun MagicCounterApp() {
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) }, title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
navigationIcon = { navigationIcon = {
if (screenStack.size > 1) { if (screenStack.size > 1) {
IconButton(onClick = { screenStack.removeLast() }) { IconButton(onClick = { screenStack.removeAt(screenStack.lastIndex) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
} }
}, },
actions = { 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) }) { IconButton(onClick = { screenStack.add(Screen.Settings) }) {
Icon(Icons.Default.Settings, contentDescription = "App settings") Icon(Icons.Default.Settings, contentDescription = "App settings")
} }
@@ -102,29 +95,40 @@ fun MagicCounterApp() {
FloatingActionButton( FloatingActionButton(
onClick = { screenStack.add(Screen.Setup) } onClick = { screenStack.add(Screen.Setup) }
) { ) {
Icon(Icons.Default.Add, contentDescription = "New game") Icon(Icons.Default.PlayArrow, contentDescription = "Start new game")
} }
} }
} }
) { paddingValues -> ) { paddingValues ->
val currentScreen = screenStack.last() val currentScreen = screenStack.last()
BackHandler(enabled = screenStack.size > 1) { screenStack.removeLast() } BackHandler(enabled = screenStack.size > 1) { screenStack.removeAt(screenStack.lastIndex) }
when (currentScreen) { when (currentScreen) {
is Screen.Home -> HomeScreen( is Screen.Home -> HomeScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxWidth()
.fillMaxSize(), .fillMaxSize(),
history = historyState.value, history = historyState.value,
onResume = { record -> screenStack.add(Screen.Game(record.id)) }, onResume = { record -> screenStack.add(Screen.Game(record.id)) },
onStopGame = { record ->
// Stop the active game by marking it as stopped
val current = historyState.value
val idx = current.indexOfFirst { it.id == record.id }
if (idx >= 0) {
val stoppedState = current[idx].state.copy(stopped = true)
val updatedRec = current[idx].copy(
state = stoppedState,
lastUpdatedEpochMs = System.currentTimeMillis(),
ongoing = false,
winnerPlayerId = null
)
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
},
onDelete = { id -> onDelete = { id ->
val newList = historyState.value.filterNot { it.id == id } val newList = historyState.value.filterNot { it.id == id }
settingsVm.saveHistory(newList) settingsVm.saveHistory(newList)
},
showClearConfirm = showClearConfirm,
onClearConfirm = { showClearConfirm = false },
onClear = {
settingsVm.saveHistory(emptyList())
showClearConfirm = false
} }
) )
is Screen.Setup -> SetupScreen( is Screen.Setup -> SetupScreen(
@@ -132,6 +136,13 @@ fun MagicCounterApp() {
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
onStart = { name, state -> onStart = { name, state ->
// Check if there's already an active game
if (historyState.value.any { it.ongoing }) {
// Navigate back to home screen if a game is already active
screenStack.removeAt(screenStack.lastIndex)
return@SetupScreen
}
// Create and persist a new MatchRecord // Create and persist a new MatchRecord
val newId = java.util.UUID.randomUUID().toString() val newId = java.util.UUID.randomUUID().toString()
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -147,7 +158,7 @@ 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)
// Replace Setup with the new Game screen (pop then push) // Replace Setup with the new Game screen (pop then push)
if (screenStack.isNotEmpty()) screenStack.removeLast() if (screenStack.isNotEmpty()) screenStack.removeAt(screenStack.lastIndex)
screenStack.add(Screen.Game(newId, state)) screenStack.add(Screen.Game(newId, state))
} }
) )
@@ -157,7 +168,7 @@ fun MagicCounterApp() {
val record = historyState.value.firstOrNull { it.id == id } val record = historyState.value.firstOrNull { it.id == id }
val stateForGame = record?.state ?: bootState val stateForGame = record?.state ?: bootState
if (stateForGame == null) { if (stateForGame == null) {
screenStack.removeLast() screenStack.removeAt(screenStack.lastIndex)
} else { } else {
GameScreen( GameScreen(
modifier = Modifier modifier = Modifier
@@ -186,6 +197,30 @@ fun MagicCounterApp() {
val newList = current.toMutableList().apply { set(idx, updatedRec) } val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList) settingsVm.saveHistory(newList)
} }
},
onStop = { stoppedState ->
val current = historyState.value
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(
state = stoppedState,
lastUpdatedEpochMs = System.currentTimeMillis(),
ongoing = false,
winnerPlayerId = null
)
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
// Navigate back to home screen
screenStack.removeAt(screenStack.lastIndex)
}
},
onDelete = {
// Delete the current game
val current = historyState.value
val newList = current.filterNot { it.id == id }
settingsVm.saveHistory(newList)
// Navigate back to home screen
screenStack.removeAt(screenStack.lastIndex)
} }
) )
} }
@@ -199,23 +234,7 @@ fun MagicCounterApp() {
) )
} }
// 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") }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") }
}
)
}
} }
} }
} }
@@ -260,95 +279,57 @@ private fun HomeScreen(
modifier: Modifier, modifier: Modifier,
history: List<MatchRecord>, history: List<MatchRecord>,
onResume: (MatchRecord) -> Unit, onResume: (MatchRecord) -> Unit,
onDelete: (String) -> Unit, onStopGame: (MatchRecord) -> Unit,
showClearConfirm: Boolean, onDelete: (String) -> Unit
onClearConfirm: () -> Unit,
onClear: () -> Unit
) { ) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { var pendingDeleteId by remember { mutableStateOf<String?>(null) }
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
if (history.isEmpty()) {
// Only show action buttons if there are ongoing games // Empty state
if (history.any { it.ongoing }) { androidx.compose.foundation.layout.Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Icon(
"Quick Actions", Icons.Default.History,
style = MaterialTheme.typography.titleMedium, contentDescription = "Empty history",
color = MaterialTheme.colorScheme.primary 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
) )
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")
}
}
} }
} }
} else {
if (history.isEmpty()) { // Games list
androidx.compose.foundation.layout.Box( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
) { verticalArrangement = Arrangement.spacedBy(12.dp)
Column( ) {
horizontalAlignment = Alignment.CenterHorizontally, // Active game at the top (if any)
verticalArrangement = Arrangement.spacedBy(16.dp) val activeGame = history.firstOrNull { it.ongoing }
) { if (activeGame != null) {
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 { 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( androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors( colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.primary
) ),
onClick = { onResume(activeGame) }
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -357,70 +338,97 @@ private fun HomeScreen(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
rec.name, "Active Game",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onPrimary
) )
Text( Text(
"Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}", activeGame.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) color = MaterialTheme.colorScheme.onPrimary
)
GameDuration(
startTime = activeGame.startedAtEpochMs,
color = MaterialTheme.colorScheme.onPrimary
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { androidx.compose.material3.FilledTonalIconButton(
IconButton(onClick = { onResume(rec) }) { onClick = { onStopGame(activeGame) },
Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") modifier = Modifier.size(56.dp),
} colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
IconButton(onClick = { pendingDeleteId = rec.id }) { containerColor = MaterialTheme.colorScheme.error,
Icon(Icons.Default.Delete, contentDescription = "Delete") contentColor = MaterialTheme.colorScheme.onError
} )
) {
Icon(
CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "Stop game",
modifier = Modifier.size(24.dp)
)
} }
} }
} }
} }
}
item { // Past games section
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp)) { val pastGames = history.filter { !it.ongoing }
Text( items(pastGames, key = { it.id }) { rec ->
"Finished Games", androidx.compose.material3.Card(
style = MaterialTheme.typography.titleLarge, modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary colors = androidx.compose.material3.CardDefaults.cardColors(
) containerColor = MaterialTheme.colorScheme.surfaceVariant
HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) ),
} onClick = { onResume(rec) }
} ) {
items(history.filter { !it.ongoing }, key = { it.id }) { rec -> Row(
androidx.compose.material3.Card( modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
colors = androidx.compose.material3.CardDefaults.cardColors( verticalAlignment = Alignment.CenterVertically
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Row( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.fillMaxWidth().padding(16.dp), Text(
horizontalArrangement = Arrangement.SpaceBetween, rec.name,
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.bodyLarge,
) { color = MaterialTheme.colorScheme.onSurfaceVariant
Column(modifier = Modifier.weight(1f)) { )
Text( val status = if (rec.state.stopped) "Stopped" else "Finished"
rec.name, val winner = rec.winnerPlayerId?.let { " • Winner: Player ${it + 1}" } ?: ""
style = MaterialTheme.typography.titleMedium, val statusText = if (winner.isNotEmpty()) "$status$winner" else status
color = MaterialTheme.colorScheme.onSurface Text(
) statusText,
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: "" style = MaterialTheme.typography.bodyMedium,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
"Finished • $winner", )
style = MaterialTheme.typography.bodyMedium, }
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.FilledTonalIconButton(
onClick = { onResume(rec) },
modifier = Modifier.size(40.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
) )
) {
Icon(
Icons.Default.Visibility,
contentDescription = "View match",
modifier = Modifier.size(20.dp)
)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { androidx.compose.material3.FilledTonalIconButton(
IconButton(onClick = { onResume(rec) }) { onClick = { pendingDeleteId = rec.id },
Icon(Icons.Default.Visibility, contentDescription = "View match") modifier = Modifier.size(40.dp),
} colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
IconButton(onClick = { pendingDeleteId = rec.id }) { containerColor = MaterialTheme.colorScheme.error,
Icon(Icons.Default.Delete, contentDescription = "Delete") contentColor = MaterialTheme.colorScheme.onError
} )
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.size(20.dp)
)
} }
} }
} }
@@ -444,20 +452,36 @@ private fun HomeScreen(
dismissButton = { TextButton(onClick = { pendingDeleteId = null }) { Text("Cancel") } } 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") } }
)
}
} }
} }
@Composable
private fun GameDuration(
startTime: Long,
color: Color
) {
var duration by remember { mutableStateOf("") }
LaunchedEffect(startTime) {
while (true) {
val elapsed = System.currentTimeMillis() - startTime
val seconds = (elapsed / 1000).toInt()
val minutes = seconds / 60
val remainingSeconds = seconds % 60
duration = when {
minutes > 0 -> "Duration: ${minutes}m ${remainingSeconds}s"
else -> "${remainingSeconds}s"
}
delay(1000)
}
}
Text(
text = duration,
style = MaterialTheme.typography.bodyMedium,
color = color.copy(alpha = 0.8f)
)
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Flag 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
@@ -24,9 +25,12 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.material3.Button
import androidx.compose.material3.AlertDialog
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.mutableStateOf
@@ -42,6 +46,7 @@ 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 com.atridad.magiccounter.ui.theme.CustomIcons
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -51,12 +56,16 @@ import com.atridad.magiccounter.ui.state.PlayerState
* - state: immutable state used to bootstrap the in-memory counters * - state: immutable state used to bootstrap the in-memory counters
* - onProgress: called with a snapshot of the latest state after any user interaction * - onProgress: called with a snapshot of the latest state after any user interaction
* - onWinner: called once when a winner is determined, with (winnerId, finalState) * - onWinner: called once when a winner is determined, with (winnerId, finalState)
* - onStop: called when the game is manually stopped
* - onDelete: called when the game is deleted
*/ */
fun GameScreen( fun GameScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: GameState, state: GameState,
onProgress: ((GameState) -> Unit)? = null, onProgress: ((GameState) -> Unit)? = null,
onWinner: ((Int, GameState) -> Unit)? = null onWinner: ((Int, GameState) -> Unit)? = null,
onStop: ((GameState) -> Unit)? = null,
onDelete: (() -> Unit)? = null
) { ) {
// Local editable state per player // Local editable state per player
val lifeTotals = remember { mutableStateMapOf<Int, Int>() } val lifeTotals = remember { mutableStateMapOf<Int, Int>() }
@@ -66,6 +75,9 @@ fun GameScreen(
val eliminated = remember { mutableStateMapOf<Int, Boolean>() } val eliminated = remember { mutableStateMapOf<Int, Boolean>() }
// Tracks whether the game has ended. When true, inputs are frozen and the winner is highlighted. // Tracks whether the game has ended. When true, inputs are frozen and the winner is highlighted.
val gameLocked = remember { mutableStateOf(false) } val gameLocked = remember { mutableStateOf(false) }
// State for stop game confirmation
val showStopConfirm = remember { mutableStateOf(false) }
// Initialize // Initialize
state.players.forEach { p -> state.players.forEach { p ->
@@ -101,7 +113,45 @@ fun GameScreen(
) )
Column(modifier = modifier.padding(12.dp)) { Column(modifier = modifier.padding(12.dp)) {
// Lock game when only one active player remains // Game status and action buttons
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Game status
Text(
text = if (state.stopped) "Game Stopped" else "Game Active",
style = MaterialTheme.typography.titleMedium,
color = if (state.stopped) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
// Action buttons (stop/delete)
if (state.stopped) {
// Show delete button for stopped games
IconButton(
onClick = { onDelete?.invoke() }
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete game",
tint = MaterialTheme.colorScheme.error
)
}
} else {
// Show stop button for active games
IconButton(
onClick = { showStopConfirm.value = true }
) {
Icon(
CustomIcons.Stop(MaterialTheme.colorScheme.error),
contentDescription = "Stop game"
)
}
}
}
// Lock game when only one active player remains or game is stopped
val aliveCount = state.players.count { eliminated[it.id] != true } val aliveCount = state.players.count { eliminated[it.id] != true }
val currentWinnerId: Int? = if (aliveCount == 1) { val currentWinnerId: Int? = if (aliveCount == 1) {
state.players.first { eliminated[it.id] != true }.id state.players.first { eliminated[it.id] != true }.id
@@ -111,6 +161,12 @@ fun GameScreen(
gameLocked.value = true gameLocked.value = true
onWinner?.invoke(currentWinnerId, snapshotState()) onWinner?.invoke(currentWinnerId, snapshotState())
} }
// If game is stopped, lock it
if (state.stopped && !gameLocked.value) {
gameLocked.value = true
}
val displayPlayers = state.players.sortedBy { eliminated[it.id] == true } val displayPlayers = state.players.sortedBy { eliminated[it.id] == true }
LazyColumn( LazyColumn(
@@ -123,6 +179,7 @@ fun GameScreen(
val perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap() val perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap()
PlayerCard( PlayerCard(
player = player, player = player,
gameState = state,
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 ->
@@ -163,6 +220,27 @@ fun GameScreen(
) )
} }
} }
// Stop game confirmation dialog
if (showStopConfirm.value) {
AlertDialog(
onDismissRequest = { showStopConfirm.value = false },
title = { Text("Stop Game?") },
text = { Text("Are you sure you want to stop this game? This will end the current session.") },
confirmButton = {
TextButton(
onClick = {
val stoppedState = snapshotState().copy(stopped = true)
onStop?.invoke(stoppedState)
showStopConfirm.value = false
}
) { Text("Stop Game") }
},
dismissButton = {
TextButton(onClick = { showStopConfirm.value = false }) { Text("Cancel") }
}
)
}
} }
} }
@@ -194,6 +272,7 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color
@Composable @Composable
private fun PlayerCard( private fun PlayerCard(
player: PlayerState, player: PlayerState,
gameState: GameState,
opponents: List<Int>, opponents: List<Int>,
life: Int, life: Int,
onLifeChange: (Int) -> Unit, onLifeChange: (Int) -> Unit,
@@ -251,12 +330,22 @@ private fun PlayerCard(
if (isEliminated) { if (isEliminated) {
// Skull overlay // Skull overlay
Text( Column(
text = "☠️", modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value),
fontSize = 64.sp, horizontalAlignment = Alignment.CenterHorizontally
color = MaterialTheme.colorScheme.error, ) {
modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value) Icon(
) CustomIcons.Skull(MaterialTheme.colorScheme.error),
contentDescription = "Player eliminated",
modifier = Modifier.size(48.dp)
)
Text(
text = "ELIMINATED",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelMedium
)
}
} }
} }
} }

View File

@@ -47,7 +47,7 @@ fun SetupScreen(
val names = remember { mutableStateListOf<String>() } val names = remember { mutableStateListOf<String>() }
LaunchedEffect(playerCount) { LaunchedEffect(playerCount) {
while (names.size < playerCount) names.add(defaultPlayerName(names.size)) while (names.size < playerCount) names.add(defaultPlayerName(names.size))
while (names.size > playerCount) names.removeLast() while (names.size > playerCount) names.removeAt(names.lastIndex)
} }
Column( Column(

View File

@@ -20,7 +20,8 @@ data class GameState(
val players: List<PlayerState>, val players: List<PlayerState>,
val startingLife: Int, val startingLife: Int,
val trackPoison: Boolean, val trackPoison: Boolean,
val trackCommanderDamage: Boolean val trackCommanderDamage: Boolean,
val stopped: Boolean = false
) )
fun defaultPlayerName(index: Int): String = "Player ${index + 1}" fun defaultPlayerName(index: Int): String = "Player ${index + 1}"

View File

@@ -0,0 +1,241 @@
package com.atridad.magiccounter.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object CustomIcons {
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Stop",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6f, 6f)
horizontalLineTo(18f)
verticalLineTo(18f)
horizontalLineTo(6f)
close()
}.build()
fun Pause(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Pause",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6f, 19f)
horizontalLineTo(10f)
verticalLineTo(5f)
horizontalLineTo(6f)
close()
moveTo(14f, 5f)
verticalLineTo(19f)
horizontalLineTo(18f)
verticalLineTo(5f)
close()
}.build()
fun Home(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Home",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(10f, 20f)
verticalLineTo(14f)
horizontalLineTo(14f)
verticalLineTo(20f)
horizontalLineTo(19f)
verticalLineTo(12f)
horizontalLineTo(22f)
lineTo(12f, 3f)
lineTo(2f, 12f)
horizontalLineTo(5f)
verticalLineTo(20f)
close()
}.build()
fun Trophy(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Trophy",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(7f, 15f)
horizontalLineTo(17f)
verticalLineTo(17f)
horizontalLineTo(5f)
verticalLineTo(15f)
close()
moveTo(7f, 13f)
horizontalLineTo(17f)
verticalLineTo(15f)
horizontalLineTo(7f)
close()
moveTo(7f, 11f)
horizontalLineTo(17f)
verticalLineTo(13f)
horizontalLineTo(7f)
close()
moveTo(7f, 9f)
horizontalLineTo(17f)
verticalLineTo(11f)
horizontalLineTo(7f)
close()
moveTo(7f, 7f)
horizontalLineTo(17f)
verticalLineTo(9f)
horizontalLineTo(7f)
close()
moveTo(7f, 5f)
horizontalLineTo(17f)
verticalLineTo(7f)
horizontalLineTo(7f)
close()
moveTo(7f, 3f)
horizontalLineTo(17f)
verticalLineTo(5f)
horizontalLineTo(7f)
close()
moveTo(7f, 1f)
horizontalLineTo(17f)
verticalLineTo(3f)
horizontalLineTo(7f)
close()
}.build()
fun Skull(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Skull",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(12f, 2f)
curveTo(13.1f, 2f, 14f, 2.9f, 14f, 4f)
curveTo(14f, 5.1f, 13.1f, 6f, 12f, 6f)
curveTo(10.9f, 6f, 10f, 5.1f, 10f, 4f)
curveTo(10f, 2.9f, 10.9f, 2f, 12f, 2f)
close()
moveTo(21f, 9f)
verticalLineTo(7f)
lineTo(15f, 7f)
lineTo(15f, 5f)
curveTo(15f, 3.9f, 14.1f, 3f, 13f, 3f)
lineTo(11f, 3f)
curveTo(9.9f, 3f, 9f, 3.9f, 9f, 5f)
lineTo(9f, 7f)
lineTo(3f, 7f)
verticalLineTo(9f)
lineTo(5f, 9f)
verticalLineTo(20f)
curveTo(5f, 21.1f, 5.9f, 22f, 7f, 22f)
lineTo(17f, 22f)
curveTo(18.1f, 22f, 19f, 21.1f, 19f, 20f)
lineTo(19f, 9f)
lineTo(21f, 9f)
close()
moveTo(12f, 18f)
curveTo(10.9f, 18f, 10f, 17.1f, 10f, 16f)
curveTo(10f, 14.9f, 10.9f, 14f, 12f, 14f)
curveTo(13.1f, 14f, 14f, 14.9f, 14f, 16f)
curveTo(14f, 17.1f, 13.1f, 18f, 12f, 18f)
close()
}.build()
fun Poison(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Poison",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(12f, 2f)
curveTo(13.1f, 2f, 14f, 2.9f, 14f, 4f)
curveTo(14f, 5.1f, 13.1f, 6f, 12f, 6f)
curveTo(10.9f, 6f, 10f, 5.1f, 10f, 4f)
curveTo(10f, 2.9f, 10.9f, 2f, 12f, 2f)
close()
moveTo(21f, 9f)
verticalLineTo(7f)
lineTo(15f, 7f)
lineTo(15f, 5f)
curveTo(15f, 3.9f, 14.1f, 3f, 13f, 3f)
lineTo(11f, 3f)
curveTo(9.9f, 3f, 9f, 3.9f, 9f, 5f)
lineTo(9f, 7f)
lineTo(3f, 7f)
verticalLineTo(9f)
lineTo(5f, 9f)
verticalLineTo(20f)
curveTo(5f, 21.1f, 5.9f, 22f, 7f, 22f)
lineTo(17f, 22f)
curveTo(18.1f, 22f, 19f, 21.1f, 19f, 20f)
lineTo(19f, 9f)
lineTo(21f, 9f)
close()
moveTo(12f, 18f)
curveTo(10.9f, 18f, 10f, 17.1f, 10f, 16f)
curveTo(10f, 14.9f, 10.9f, 14f, 12f, 14f)
curveTo(13.1f, 14f, 14f, 14.9f, 14f, 16f)
curveTo(14f, 17.1f, 13.1f, 14f, 12f, 18f)
close()
}.build()
fun Sword(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Sword",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6.92f, 5f)
horizontalLineTo(5.14f)
lineTo(4.5f, 5.64f)
lineTo(6.92f, 8.07f)
lineTo(6.92f, 5f)
close()
moveTo(19.5f, 8.5f)
lineTo(18.79f, 9.21f)
lineTo(12.71f, 15.29f)
lineTo(13.41f, 16f)
horizontalLineTo(17f)
verticalLineTo(22f)
horizontalLineTo(19f)
verticalLineTo(16f)
horizontalLineTo(22.59f)
lineTo(23.29f, 15.29f)
lineTo(17.21f, 9.21f)
lineTo(16.5f, 8.5f)
lineTo(19.5f, 8.5f)
close()
moveTo(6.92f, 19f)
verticalLineTo(16.93f)
lineTo(4.5f, 19.36f)
lineTo(5.14f, 20f)
horizontalLineTo(6.92f)
verticalLineTo(19f)
close()
}.build()
}