6 Commits
1.0.0 ... 1.2.0

12 changed files with 789 additions and 182 deletions

6
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Firebase Crashlytics" />
</component>
</project>

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>

2
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?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

@@ -1,2 +1,18 @@
# MagicCounter # MagicCounter
This is a FOSS Android app meant to allow MTG Commander players to keep track of player health and commander damage. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support.
## Download
You have two options:
1. Download the latest APK from the Released page
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.magiccounter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FMagicCounter%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22MagicCounter%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
## Requirements
- Android 15+
## Contribution
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.

View File

@@ -7,34 +7,43 @@ plugins {
android { android {
namespace = "com.atridad.magiccounter" namespace = "com.atridad.magiccounter"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.magiccounter" applicationId = "com.atridad.magiccounter"
minSdk = 35 minSdk = 31
targetSdk = 35 targetSdk = 36
versionCode = 1 versionCode = 2
versionName = "1.0.0" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
} }
} }
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

@@ -6,6 +6,7 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
@@ -13,8 +14,8 @@ 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.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.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
@@ -26,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
@@ -42,9 +46,8 @@ 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 androidx.compose.material3.FilterChip 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
@@ -64,6 +67,7 @@ fun MagicCounterApp() {
val historyState = settingsVm.matchHistory.collectAsState() val historyState = settingsVm.matchHistory.collectAsState()
MagicCounterTheme(themeMode = theme.value) { MagicCounterTheme(themeMode = theme.value) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -71,40 +75,74 @@ 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 = {
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")
} }
} }
) )
},
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.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,
onNewGame = { screenStack.add(Screen.Setup) },
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)
}, }
onClear = { settingsVm.saveHistory(emptyList()) }
) )
is Screen.Setup -> SetupScreen( is Screen.Setup -> SetupScreen(
modifier = Modifier modifier = Modifier
.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()
@@ -120,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))
} }
) )
@@ -130,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
@@ -159,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)
} }
) )
} }
@@ -171,21 +233,42 @@ fun MagicCounterApp() {
onSelect = { settingsVm.setTheme(it) } onSelect = { settingsVm.setTheme(it) }
) )
} }
} }
} }
} }
@Composable @Composable
private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) { private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
Text("Theme") modifier = modifier.padding(24.dp),
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(24.dp)
ThemeMode.entries.forEach { mode -> ) {
FilterChip( Text(
selected = current == mode, "Appearance",
onClick = { onSelect(mode) }, style = MaterialTheme.typography.headlineSmall,
label = { Text(mode.name) } 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,75 +278,158 @@ private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (T
private fun HomeScreen( private fun HomeScreen(
modifier: Modifier, modifier: Modifier,
history: List<MatchRecord>, history: List<MatchRecord>,
onNewGame: () -> Unit,
onResume: (MatchRecord) -> Unit, onResume: (MatchRecord) -> Unit,
onDelete: (String) -> Unit, onStopGame: (MatchRecord) -> Unit,
onClear: () -> Unit onDelete: (String) -> 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) }
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") }
}
if (history.isEmpty()) { if (history.isEmpty()) {
androidx.compose.foundation.layout.Box( // Empty state
modifier = Modifier.fillMaxSize(), androidx.compose.foundation.layout.Box(
contentAlignment = Alignment.Center modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { Icon(
Icon(Icons.Default.History, contentDescription = "Empty history") Icons.Default.History,
Text("Nothing to see here") contentDescription = "Empty history",
Text("Start a new game to begin.") 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 {
// Games list
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Active game at the top (if any)
val activeGame = history.firstOrNull { it.ongoing }
if (activeGame != null) {
item {
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary
),
onClick = { onResume(activeGame) }
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Active Game",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary
)
Text(
activeGame.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimary
)
GameDuration(
startTime = activeGame.startedAtEpochMs,
color = MaterialTheme.colorScheme.onPrimary
)
}
androidx.compose.material3.FilledTonalIconButton(
onClick = { onStopGame(activeGame) },
modifier = Modifier.size(56.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(
CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "Stop game",
modifier = Modifier.size(24.dp)
)
}
}
}
} }
} }
} else {
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
)
HorizontalDivider()
}
}
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 { // Past games section
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp)) { val pastGames = history.filter { !it.ongoing }
Text( items(pastGames, key = { it.id }) { rec ->
"Finished", 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() ),
} onClick = { onResume(rec) }
} ) {
items(history.filter { !it.ongoing }, key = { it.id }) { rec -> Row(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { modifier = Modifier.fillMaxWidth().padding(16.dp),
Column { horizontalArrangement = Arrangement.SpaceBetween,
Text(rec.name) verticalAlignment = Alignment.CenterVertically
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: "" ) {
Text("Finished • $winner") Column(modifier = Modifier.weight(1f)) {
Text(
rec.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val status = if (rec.state.stopped) "Stopped" else "Finished"
val winner = rec.winnerPlayerId?.let { " • Winner: Player ${it + 1}" } ?: ""
val statusText = if (winner.isNotEmpty()) "$status$winner" else status
Text(
statusText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
} }
Row { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") } androidx.compose.material3.FilledTonalIconButton(
IconButton(onClick = { pendingDeleteId = rec.id }) { Icon(Icons.Default.Delete, contentDescription = "Delete") } 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)
)
}
androidx.compose.material3.FilledTonalIconButton(
onClick = { pendingDeleteId = rec.id },
modifier = Modifier.size(40.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.size(20.dp)
)
}
} }
} }
} }
@@ -286,23 +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 = { 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") } }
)
}
} }
} }
@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

@@ -9,8 +9,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -44,80 +47,143 @@ 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(
modifier = modifier modifier = modifier
.padding(16.dp) .padding(24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
Text("Starting life: $startingLife") // Game Settings Section
Slider( Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
value = startingLife.toFloat(), Text(
onValueChange = { "Game Settings",
val snapped = ((it / 5f).roundToInt() * 5).coerceIn(10, 40) style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
startingLife = snapped color = androidx.compose.material3.MaterialTheme.colorScheme.primary
},
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
)
) )
}, 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)
} }
} }
} }

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()
}

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.9.0" agp = "8.12.1"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"

View File

@@ -1,6 +1,6 @@
#Sat Aug 09 23:53:05 MDT 2025 #Sat Aug 09 23:53:05 MDT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists