From 98ca6e676c9b7ffe74c48bce558d8504ab228814 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 22 Aug 2025 00:14:01 -0600 Subject: [PATCH] 1.1.0 - Better Material Design Compliance --- .idea/appInsightsSettings.xml | 6 + .idea/misc.xml | 1 - app/build.gradle.kts | 8 +- .../magiccounter/ui/MagicCounterApp.kt | 255 ++++++++++++++---- .../magiccounter/ui/screens/SetupScreen.kt | 198 +++++++++----- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 349 insertions(+), 123 deletions(-) create mode 100644 .idea/appInsightsSettings.xml diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..23b2e1f --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index edd88b5..25a4269 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,14 +7,14 @@ plugins { android { namespace = "com.atridad.magiccounter" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.atridad.magiccounter" minSdk = 35 - targetSdk = 35 - versionCode = 1 - versionName = "1.0.0" + targetSdk = 36 + versionCode = 2 + versionName = "1.1.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 56f4e3a..4fc59c8 100644 --- a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt +++ b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt @@ -6,15 +6,19 @@ 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.History 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 @@ -42,11 +46,11 @@ 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.FilterChip 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 private sealed class Screen { data object Home : Screen() @@ -63,6 +67,8 @@ fun MagicCounterApp() { 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( @@ -77,11 +83,28 @@ fun MagicCounterApp() { } }, 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 -> val currentScreen = screenStack.last() @@ -92,13 +115,17 @@ fun MagicCounterApp() { .padding(paddingValues) .fillMaxSize(), history = historyState.value, - 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()) } + showClearConfirm = showClearConfirm, + onClearConfirm = { showClearConfirm = false }, + onClear = { + settingsVm.saveHistory(emptyList()) + showClearConfirm = false + } ) is Screen.Setup -> SetupScreen( modifier = Modifier @@ -171,21 +198,58 @@ fun MagicCounterApp() { 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") } + }, + dismissButton = { + TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } + } + ) + } } } } @Composable 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.entries.forEach { mode -> - FilterChip( - selected = current == mode, - onClick = { onSelect(mode) }, - label = { Text(mode.name) } - ) + 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) + ) + } } } } @@ -195,17 +259,48 @@ private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (T private fun HomeScreen( modifier: Modifier, history: List, - onNewGame: () -> Unit, onResume: (MatchRecord) -> Unit, onDelete: (String) -> Unit, + showClearConfirm: Boolean, + onClearConfirm: () -> Unit, onClear: () -> Unit ) { Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { 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") } + + // 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") + } + } + } } if (history.isEmpty()) { @@ -213,57 +308,120 @@ private fun HomeScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.History, contentDescription = "Empty history") - Text("Nothing to see here") - Text("Start a new game to begin.") + 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 + ) } } } else { LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { item { - Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 12.dp)) { Text( - "Ongoing", + "Ongoing Games", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) - HorizontalDivider() + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) } } 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") } + 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 = 8.dp, bottom = 4.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp)) { Text( - "Finished", + "Finished Games", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) - HorizontalDivider() + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) } } 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") } + 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") + } + } } } } @@ -290,16 +448,13 @@ private fun HomeScreen( // Confirm clear dialog if (showClearConfirm) { AlertDialog( - onDismissRequest = { showClearConfirm = false }, + onDismissRequest = { onClearConfirm() }, title = { Text("Clear history?") }, text = { Text("This will remove all matches. This action cannot be undone.") }, confirmButton = { - TextButton(onClick = { - onClear() - showClearConfirm = false - }) { Text("Clear") } + TextButton(onClick = { onClear() }) { Text("Clear") } }, - dismissButton = { TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } } + dismissButton = { TextButton(onClick = { onClearConfirm() }) { 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 b34e8e0..0a422e7 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 @@ -9,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 @@ -49,75 +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 = { - val snapped = ((it / 5f).roundToInt() * 5).coerceIn(10, 40) - startingLife = snapped - }, - valueRange = 10f..40f, - steps = 5 - ) - 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") - } - - 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)) - 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 - ) + // 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 ) - }, enabled = canStart) { - 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) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1d195c..3d74cbe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7461975..7b9be93 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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