From 35228d93743a6d2720201d5d00c1670748ad7cdc Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Sun, 10 Aug 2025 21:40:49 -0600 Subject: [PATCH] 0.2.0 - Bugfixes and deleting of matches --- app/build.gradle.kts | 2 +- .../magiccounter/ui/MagicCounterApp.kt | 275 +++++++++++------- .../magiccounter/ui/screens/SetupScreen.kt | 8 +- 3 files changed, 176 insertions(+), 109 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5cd2243..56be14b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ android { minSdk = 35 targetSdk = 35 versionCode = 1 - versionName = "0.1.1" + versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt index 4f6abb7..9f8e553 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt @@ -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(null) } - var showSettings by remember { mutableStateOf(false) } - var activeMatchId by remember { mutableStateOf(null) } - var screen by remember { mutableStateOf(Screen.Home) } + val screenStack: SnapshotStateList = 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, 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, 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(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") } } + ) } } } diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt index 3209750..33f0321 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt @@ -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") } }