1 Commits
0.1.1 ... 0.2.0

Author SHA1 Message Date
35228d9374 0.2.0 - Bugfixes and deleting of matches 2025-08-10 21:40:49 -06:00
3 changed files with 176 additions and 109 deletions

View File

@@ -14,7 +14,7 @@ android {
minSdk = 35 minSdk = 35
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.1" versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -6,6 +6,7 @@ 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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -14,20 +15,26 @@ import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home 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.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.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.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.screens.GameScreen import com.atridad.magiccounter.ui.screens.GameScreen
@@ -40,19 +47,25 @@ 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.compose.material3.ModalBottomSheet
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.activity.compose.BackHandler
import androidx.compose.material3.Divider
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// no-op // no-op
// Top-level navigation destinations // Top-level navigation destinations using a simple view stack
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) @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()
@@ -63,33 +76,38 @@ fun MagicCounterApp() {
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.Default.ArrowBack, contentDescription = "Back")
}
}
},
actions = { actions = {
if (screen != Screen.Home) { IconButton(onClick = { screenStack.add(Screen.Settings) }) {
IconButton(onClick = { screen = Screen.Home }) {
Icon(Icons.Default.Home, contentDescription = "Home")
}
}
IconButton(onClick = { showSettings = true }) {
Icon(Icons.Default.Settings, contentDescription = "App settings") Icon(Icons.Default.Settings, contentDescription = "App settings")
} }
} }
) )
} }
) { 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 }, onNewGame = { screenStack.add(Screen.Setup) },
onResume = { record -> onResume = { record -> screenStack.add(Screen.Game(record.id)) },
activeMatchId = record.id onDelete = { id ->
gameState = record.state val newList = historyState.value.filterNot { it.id == id }
screen = Screen.Game settingsVm.saveHistory(newList)
} },
onClear = { settingsVm.saveHistory(emptyList()) }
) )
Screen.Setup -> SetupScreen( is Screen.Setup -> SetupScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
@@ -108,33 +126,36 @@ 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 as Screen.Game).matchId
val bootState = (currentScreen as Screen.Game).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 }, onEnd = { screenStack.removeLast() },
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,12 +168,13 @@ 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) }
) )
@@ -160,11 +182,10 @@ fun MagicCounterApp() {
} }
} }
} }
}
@Composable @Composable
private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) { private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Theme") Text("Theme")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeMode.values().forEach { mode -> ThemeMode.values().forEach { mode ->
@@ -178,59 +199,107 @@ private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
} }
} }
@Composable // History sheet removed; history presented on HomeScreen
private fun HistorySheet(history: List<MatchRecord>, onResume: (MatchRecord) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Match history")
history.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val status = if (rec.ongoing) "Ongoing" else "Finished"
Text("$status • Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
rec.winnerPlayerId?.let { Text("Winner: Player ${it + 1}") }
}
IconButton(onClick = { onResume(rec) }) {
Icon(Icons.Default.Refresh, contentDescription = "Resume")
}
}
}
}
}
@Composable @Composable
private fun HomeScreen( private fun HomeScreen(
modifier: Modifier, modifier: Modifier,
history: List<MatchRecord>, history: List<MatchRecord>,
onNewGame: () -> Unit, onNewGame: () -> Unit,
onResume: (MatchRecord) -> Unit onResume: (MatchRecord) -> Unit,
onDelete: (String) -> 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)) {
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
var showClearConfirm by remember { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onNewGame) { Text("New game") } Button(onClick = onNewGame) { Text("New game") }
Button(onClick = { showClearConfirm = true }) { Text("Clear history") }
}
Text("Ongoing") LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
history.filter { it.ongoing }.forEach { rec -> item {
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) { Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp)) {
Text(
"Ongoing",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Divider()
}
}
items(history.filter { it.ongoing }, key = { it.id }) { rec ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column { Column {
Text(rec.name) Text(rec.name)
Text("Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}") Text("Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
} }
Row {
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") } IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") }
IconButton(onClick = { pendingDeleteId = rec.id }) { Icon(Icons.Default.Delete, contentDescription = "Delete") }
}
} }
} }
Text("Finished") item {
history.filter { !it.ongoing }.forEach { rec -> Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp)) {
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) { Text(
"Finished",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Divider()
}
}
items(history.filter { !it.ongoing }, key = { it.id }) { rec ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column { Column {
Text(rec.name) Text(rec.name)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: "" val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner") Text("Finished • $winner")
} }
Row {
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") } 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 = { showClearConfirm = false },
title = { Text("Clear history?") },
text = { Text("This will remove all matches. This action cannot be undone.") },
confirmButton = {
TextButton(onClick = {
onClear()
showClearConfirm = false
}) { Text("Clear") }
},
dismissButton = { TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } }
)
}
}
}

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
@@ -32,7 +30,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,
@@ -109,6 +106,7 @@ fun SetupScreen(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val canStart = matchName.isNotBlank()
Button(onClick = { Button(onClick = {
val players = names.mapIndexed { index, name -> val players = names.mapIndexed { index, name ->
PlayerState( PlayerState(
@@ -122,7 +120,7 @@ fun SetupScreen(
) )
} }
onStart( onStart(
matchName.ifBlank { "Game ${System.currentTimeMillis()}" }, matchName,
GameState( GameState(
players = players, players = players,
startingLife = startingLife, startingLife = startingLife,
@@ -132,7 +130,7 @@ fun SetupScreen(
trackCommanderDamage = trackCommander trackCommanderDamage = trackCommander
) )
) )
}) { }, enabled = canStart) {
Text("Start game") Text("Start game")
} }
} }