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
targetSdk = 35
versionCode = 1
versionName = "0.1.1"
versionName = "0.2.0"
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
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.PlayArrow
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.AlertDialog
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.unit.dp
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 androidx.compose.material3.ModalBottomSheet
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
// Top-level navigation destinations
private enum class Screen { Home, Setup, Game }
// Top-level navigation destinations using a simple view stack
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()
@@ -63,33 +76,38 @@ fun MagicCounterApp() {
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.Default.ArrowBack, contentDescription = "Back")
}
}
IconButton(onClick = { showSettings = true }) {
},
actions = {
IconButton(onClick = { screenStack.add(Screen.Settings) }) {
Icon(Icons.Default.Settings, contentDescription = "App settings")
}
}
)
}
) { 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
}
onNewGame = { screenStack.add(Screen.Setup) },
onResume = { record -> screenStack.add(Screen.Game(record.id)) },
onDelete = { id ->
val newList = historyState.value.filterNot { it.id == id }
settingsVm.saveHistory(newList)
},
onClear = { settingsVm.saveHistory(emptyList()) }
)
Screen.Setup -> SetupScreen(
is Screen.Setup -> SetupScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
@@ -108,63 +126,66 @@ 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 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
.padding(paddingValues)
.fillMaxSize(),
state = stateForGame,
onEnd = { screenStack.removeLast() },
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)
}
}
},
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)
}
}
}
current = theme.value,
onSelect = { settingsVm.setTheme(it) }
)
}
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)) {
private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Theme")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeMode.values().forEach { mode ->
@@ -178,57 +199,105 @@ private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
}
}
@Composable
private fun HistorySheet(history: List<MatchRecord>, onResume: (MatchRecord) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Match history")
history.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val status = if (rec.ongoing) "Ongoing" else "Finished"
Text("$status • Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
rec.winnerPlayerId?.let { Text("Winner: Player ${it + 1}") }
}
IconButton(onClick = { onResume(rec) }) {
Icon(Icons.Default.Refresh, contentDescription = "Resume")
}
}
}
}
}
// History sheet removed; history presented on HomeScreen
@Composable
private fun HomeScreen(
modifier: Modifier,
history: List<MatchRecord>,
onNewGame: () -> Unit,
onResume: (MatchRecord) -> Unit
onResume: (MatchRecord) -> Unit,
onDelete: (String) -> Unit,
onClear: () -> Unit
) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNewGame) { Text("New game") }
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 = { showClearConfirm = true }) { Text("Clear history") }
}
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))}")
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item {
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 {
Text(rec.name)
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 = { pendingDeleteId = rec.id }) { Icon(Icons.Default.Delete, contentDescription = "Delete") }
}
}
}
item {
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp)) {
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 {
Text(rec.name)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner")
}
Row {
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") }
IconButton(onClick = { pendingDeleteId = rec.id }) { Icon(Icons.Default.Delete, contentDescription = "Delete") }
}
}
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") }
}
}
Text("Finished")
history.filter { !it.ongoing }.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner")
}
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") }
}
// 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
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
@@ -32,7 +30,6 @@ 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,
@@ -109,6 +106,7 @@ fun SetupScreen(
}
Spacer(modifier = Modifier.height(8.dp))
val canStart = matchName.isNotBlank()
Button(onClick = {
val players = names.mapIndexed { index, name ->
PlayerState(
@@ -122,7 +120,7 @@ fun SetupScreen(
)
}
onStart(
matchName.ifBlank { "Game ${System.currentTimeMillis()}" },
matchName,
GameState(
players = players,
startingLife = startingLife,
@@ -132,7 +130,7 @@ fun SetupScreen(
trackCommanderDamage = trackCommander
)
)
}) {
}, enabled = canStart) {
Text("Start game")
}
}