6 Commits

Author SHA1 Message Date
88be16c0c6 oops 2025-12-08 00:16:01 -07:00
766f72dcfa 1.0.0 for iOS 2025-12-07 23:58:13 -07:00
ff6009f237 ?? 2025-12-07 01:36:59 -07:00
9dea9f6efa Turned into a monorepo for iOS and Android 2025-12-07 01:36:53 -07:00
dcb8b8401b Update README.md 2025-09-01 07:20:36 +00:00
c721b5550f 1.2.0 - UX Improvements 2025-08-22 12:58:33 -06:00
92 changed files with 2418 additions and 200 deletions

View File

@@ -1,17 +1,24 @@
# 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. This is a FOSS Android and iOS 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.
## Download ## Download
### Android
You have two options: You have two options:
1. Download the latest APK from the Released page 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> 2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](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)
### iOS
TBD
## Requirements ## Requirements
- Android 15+ - Android 15+
- iOS 17+
## Contribution ## Contribution

View File

View File

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>

View File

@@ -1,6 +1,7 @@
<?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

@@ -11,10 +11,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.atridad.magiccounter" applicationId = "com.atridad.magiccounter"
minSdk = 35 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 2
versionName = "1.1.0" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -28,13 +28,22 @@ android {
) )
} }
} }
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

@@ -12,11 +12,8 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -30,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
@@ -46,11 +46,10 @@ 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 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
import androidx.compose.foundation.layout.Spacer
private sealed class Screen { private sealed class Screen {
data object Home : Screen() data object Home : Screen()
@@ -67,8 +66,7 @@ fun MagicCounterApp() {
val theme = settingsVm.themeMode.collectAsState() val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState() val historyState = settingsVm.matchHistory.collectAsState()
// State for clear history confirmation
var showClearConfirm by remember { mutableStateOf(false) }
MagicCounterTheme(themeMode = theme.value) { MagicCounterTheme(themeMode = theme.value) {
Scaffold( Scaffold(
@@ -77,18 +75,13 @@ 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 = {
// 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) }) { IconButton(onClick = { screenStack.add(Screen.Settings) }) {
Icon(Icons.Default.Settings, contentDescription = "App settings") Icon(Icons.Default.Settings, contentDescription = "App settings")
} }
@@ -102,29 +95,40 @@ fun MagicCounterApp() {
FloatingActionButton( FloatingActionButton(
onClick = { screenStack.add(Screen.Setup) } onClick = { screenStack.add(Screen.Setup) }
) { ) {
Icon(Icons.Default.Add, contentDescription = "New game") 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,
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)
},
showClearConfirm = showClearConfirm,
onClearConfirm = { showClearConfirm = false },
onClear = {
settingsVm.saveHistory(emptyList())
showClearConfirm = false
} }
) )
is Screen.Setup -> SetupScreen( is Screen.Setup -> SetupScreen(
@@ -132,6 +136,13 @@ fun MagicCounterApp() {
.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()
@@ -147,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))
} }
) )
@@ -157,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
@@ -186,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)
} }
) )
} }
@@ -199,23 +234,7 @@ fun MagicCounterApp() {
) )
} }
// 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") }
}
)
}
} }
} }
} }
@@ -260,95 +279,57 @@ private fun HomeScreen(
modifier: Modifier, modifier: Modifier,
history: List<MatchRecord>, history: List<MatchRecord>,
onResume: (MatchRecord) -> Unit, onResume: (MatchRecord) -> Unit,
onDelete: (String) -> Unit, onStopGame: (MatchRecord) -> Unit,
showClearConfirm: Boolean, onDelete: (String) -> Unit
onClearConfirm: () -> Unit,
onClear: () -> 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) }
// Only show action buttons if there are ongoing games if (history.isEmpty()) {
if (history.any { it.ongoing }) { // Empty state
androidx.compose.foundation.layout.Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Icon(
"Quick Actions", Icons.Default.History,
style = MaterialTheme.typography.titleMedium, contentDescription = "Empty history",
color = MaterialTheme.colorScheme.primary 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
) )
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")
}
}
} }
} }
} else {
if (history.isEmpty()) { // Games list
androidx.compose.foundation.layout.Box( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
) { verticalArrangement = Arrangement.spacedBy(12.dp)
Column( ) {
horizontalAlignment = Alignment.CenterHorizontally, // Active game at the top (if any)
verticalArrangement = Arrangement.spacedBy(16.dp) val activeGame = history.firstOrNull { it.ongoing }
) { if (activeGame != null) {
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 { item {
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 12.dp)) {
Text(
"Ongoing Games",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
}
items(history.filter { it.ongoing }, key = { it.id }) { rec ->
androidx.compose.material3.Card( androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors( colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.primary
) ),
onClick = { onResume(activeGame) }
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -357,70 +338,97 @@ private fun HomeScreen(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
rec.name, "Active Game",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onPrimary
) )
Text( Text(
"Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}", activeGame.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) color = MaterialTheme.colorScheme.onPrimary
)
GameDuration(
startTime = activeGame.startedAtEpochMs,
color = MaterialTheme.colorScheme.onPrimary
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { androidx.compose.material3.FilledTonalIconButton(
IconButton(onClick = { onResume(rec) }) { onClick = { onStopGame(activeGame) },
Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") modifier = Modifier.size(56.dp),
} colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
IconButton(onClick = { pendingDeleteId = rec.id }) { containerColor = MaterialTheme.colorScheme.error,
Icon(Icons.Default.Delete, contentDescription = "Delete") contentColor = MaterialTheme.colorScheme.onError
} )
) {
Icon(
CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "Stop game",
modifier = Modifier.size(24.dp)
)
} }
} }
} }
} }
}
item { // Past games section
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp)) { val pastGames = history.filter { !it.ongoing }
Text( items(pastGames, key = { it.id }) { rec ->
"Finished Games", 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(modifier = Modifier.padding(top = 8.dp)) ),
} onClick = { onResume(rec) }
} ) {
items(history.filter { !it.ongoing }, key = { it.id }) { rec -> Row(
androidx.compose.material3.Card( modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
colors = androidx.compose.material3.CardDefaults.cardColors( verticalAlignment = Alignment.CenterVertically
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Row( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.fillMaxWidth().padding(16.dp), Text(
horizontalArrangement = Arrangement.SpaceBetween, rec.name,
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.bodyLarge,
) { color = MaterialTheme.colorScheme.onSurfaceVariant
Column(modifier = Modifier.weight(1f)) { )
Text( val status = if (rec.state.stopped) "Stopped" else "Finished"
rec.name, val winner = rec.winnerPlayerId?.let { " • Winner: Player ${it + 1}" } ?: ""
style = MaterialTheme.typography.titleMedium, val statusText = if (winner.isNotEmpty()) "$status$winner" else status
color = MaterialTheme.colorScheme.onSurface Text(
statusText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.FilledTonalIconButton(
onClick = { onResume(rec) },
modifier = Modifier.size(40.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
) )
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: "" ) {
Text( Icon(
"Finished • $winner", Icons.Default.Visibility,
style = MaterialTheme.typography.bodyMedium, contentDescription = "View match",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) modifier = Modifier.size(20.dp)
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { androidx.compose.material3.FilledTonalIconButton(
IconButton(onClick = { onResume(rec) }) { onClick = { pendingDeleteId = rec.id },
Icon(Icons.Default.Visibility, contentDescription = "View match") modifier = Modifier.size(40.dp),
} colors = androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors(
IconButton(onClick = { pendingDeleteId = rec.id }) { containerColor = MaterialTheme.colorScheme.error,
Icon(Icons.Default.Delete, contentDescription = "Delete") contentColor = MaterialTheme.colorScheme.onError
} )
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.size(20.dp)
)
} }
} }
} }
@@ -444,20 +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 = { onClearConfirm() },
title = { Text("Clear history?") },
text = { Text("This will remove all matches. This action cannot be undone.") },
confirmButton = {
TextButton(onClick = { onClear() }) { Text("Clear") }
},
dismissButton = { TextButton(onClick = { onClearConfirm() }) { 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>() }
@@ -67,6 +76,9 @@ fun GameScreen(
// 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 ->
lifeTotals.putIfAbsent(p.id, p.life) lifeTotals.putIfAbsent(p.id, p.life)
@@ -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

@@ -47,7 +47,7 @@ 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(

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

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

View File

View File

@@ -0,0 +1,603 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXContainerItemProxy section */
D2DB9B832EE4DD5100372366 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D2DB9B6B2EE4DD5000372366 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D2DB9B722EE4DD5000372366;
remoteInfo = MagicCounter;
};
D2DB9B8D2EE4DD5100372366 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D2DB9B6B2EE4DD5000372366 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D2DB9B722EE4DD5000372366;
remoteInfo = MagicCounter;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
D2D1FABE2EE4DFF4000700F5 /* MagicCounter.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = MagicCounter.xcodeproj; sourceTree = "<group>"; };
D2DB9B732EE4DD5000372366 /* MagicCounter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MagicCounter.app; sourceTree = BUILT_PRODUCTS_DIR; };
D2DB9B822EE4DD5100372366 /* MagicCounterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MagicCounterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D2DB9B8C2EE4DD5100372366 /* MagicCounterUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MagicCounterUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
D2DB9B752EE4DD5000372366 /* MagicCounter */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MagicCounter;
sourceTree = "<group>";
};
D2DB9B852EE4DD5100372366 /* MagicCounterTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MagicCounterTests;
sourceTree = "<group>";
};
D2DB9B8F2EE4DD5100372366 /* MagicCounterUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MagicCounterUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
D2DB9B702EE4DD5000372366 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2DB9B7F2EE4DD5100372366 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2DB9B892EE4DD5100372366 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
D2D1FAC22EE4DFF4000700F5 /* Products */ = {
isa = PBXGroup;
children = (
);
name = Products;
sourceTree = "<group>";
};
D2DB9B6A2EE4DD5000372366 = {
isa = PBXGroup;
children = (
D2DB9B752EE4DD5000372366 /* MagicCounter */,
D2DB9B852EE4DD5100372366 /* MagicCounterTests */,
D2DB9B8F2EE4DD5100372366 /* MagicCounterUITests */,
D2DB9B742EE4DD5000372366 /* Products */,
);
sourceTree = "<group>";
};
D2DB9B742EE4DD5000372366 /* Products */ = {
isa = PBXGroup;
children = (
D2DB9B732EE4DD5000372366 /* MagicCounter.app */,
D2DB9B822EE4DD5100372366 /* MagicCounterTests.xctest */,
D2DB9B8C2EE4DD5100372366 /* MagicCounterUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D2DB9B722EE4DD5000372366 /* MagicCounter */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2DB9B962EE4DD5100372366 /* Build configuration list for PBXNativeTarget "MagicCounter" */;
buildPhases = (
D2DB9B6F2EE4DD5000372366 /* Sources */,
D2DB9B702EE4DD5000372366 /* Frameworks */,
D2DB9B712EE4DD5000372366 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
D2DB9B752EE4DD5000372366 /* MagicCounter */,
);
name = MagicCounter;
packageProductDependencies = (
);
productName = MagicCounter;
productReference = D2DB9B732EE4DD5000372366 /* MagicCounter.app */;
productType = "com.apple.product-type.application";
};
D2DB9B812EE4DD5100372366 /* MagicCounterTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2DB9B992EE4DD5100372366 /* Build configuration list for PBXNativeTarget "MagicCounterTests" */;
buildPhases = (
D2DB9B7E2EE4DD5100372366 /* Sources */,
D2DB9B7F2EE4DD5100372366 /* Frameworks */,
D2DB9B802EE4DD5100372366 /* Resources */,
);
buildRules = (
);
dependencies = (
D2DB9B842EE4DD5100372366 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D2DB9B852EE4DD5100372366 /* MagicCounterTests */,
);
name = MagicCounterTests;
packageProductDependencies = (
);
productName = MagicCounterTests;
productReference = D2DB9B822EE4DD5100372366 /* MagicCounterTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
D2DB9B8B2EE4DD5100372366 /* MagicCounterUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2DB9B9C2EE4DD5100372366 /* Build configuration list for PBXNativeTarget "MagicCounterUITests" */;
buildPhases = (
D2DB9B882EE4DD5100372366 /* Sources */,
D2DB9B892EE4DD5100372366 /* Frameworks */,
D2DB9B8A2EE4DD5100372366 /* Resources */,
);
buildRules = (
);
dependencies = (
D2DB9B8E2EE4DD5100372366 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D2DB9B8F2EE4DD5100372366 /* MagicCounterUITests */,
);
name = MagicCounterUITests;
packageProductDependencies = (
);
productName = MagicCounterUITests;
productReference = D2DB9B8C2EE4DD5100372366 /* MagicCounterUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
D2DB9B6B2EE4DD5000372366 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 2610;
TargetAttributes = {
D2DB9B722EE4DD5000372366 = {
CreatedOnToolsVersion = 26.1.1;
};
D2DB9B812EE4DD5100372366 = {
CreatedOnToolsVersion = 26.1.1;
TestTargetID = D2DB9B722EE4DD5000372366;
};
D2DB9B8B2EE4DD5100372366 = {
CreatedOnToolsVersion = 26.1.1;
TestTargetID = D2DB9B722EE4DD5000372366;
};
};
};
buildConfigurationList = D2DB9B6E2EE4DD5000372366 /* Build configuration list for PBXProject "MagicCounter" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = D2DB9B6A2EE4DD5000372366;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = D2DB9B742EE4DD5000372366 /* Products */;
projectDirPath = "";
projectReferences = (
{
ProductGroup = D2D1FAC22EE4DFF4000700F5 /* Products */;
ProjectRef = D2D1FABE2EE4DFF4000700F5 /* MagicCounter.xcodeproj */;
},
);
projectRoot = "";
targets = (
D2DB9B722EE4DD5000372366 /* MagicCounter */,
D2DB9B812EE4DD5100372366 /* MagicCounterTests */,
D2DB9B8B2EE4DD5100372366 /* MagicCounterUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
D2DB9B712EE4DD5000372366 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2DB9B802EE4DD5100372366 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2DB9B8A2EE4DD5100372366 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
D2DB9B6F2EE4DD5000372366 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2DB9B7E2EE4DD5100372366 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2DB9B882EE4DD5100372366 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D2DB9B842EE4DD5100372366 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D2DB9B722EE4DD5000372366 /* MagicCounter */;
targetProxy = D2DB9B832EE4DD5100372366 /* PBXContainerItemProxy */;
};
D2DB9B8E2EE4DD5100372366 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D2DB9B722EE4DD5000372366 /* MagicCounter */;
targetProxy = D2DB9B8D2EE4DD5100372366 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
D2DB9B942EE4DD5100372366 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
D2DB9B952EE4DD5100372366 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
D2DB9B972EE4DD5100372366 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = MagicCounter;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.MagicCounter;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D2DB9B982EE4DD5100372366 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = MagicCounter;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.MagicCounter;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
D2DB9B9A2EE4DD5100372366 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.MagicCounter.MagicCounterTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MagicCounter.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MagicCounter";
};
name = Debug;
};
D2DB9B9B2EE4DD5100372366 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.MagicCounter.MagicCounterTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MagicCounter.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MagicCounter";
};
name = Release;
};
D2DB9B9D2EE4DD5100372366 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.MagicCounter.MagicCounterUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = MagicCounter;
};
name = Debug;
};
D2DB9B9E2EE4DD5100372366 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.MagicCounter.MagicCounterUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = MagicCounter;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
D2DB9B6E2EE4DD5000372366 /* Build configuration list for PBXProject "MagicCounter" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2DB9B942EE4DD5100372366 /* Debug */,
D2DB9B952EE4DD5100372366 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D2DB9B962EE4DD5100372366 /* Build configuration list for PBXNativeTarget "MagicCounter" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2DB9B972EE4DD5100372366 /* Debug */,
D2DB9B982EE4DD5100372366 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D2DB9B992EE4DD5100372366 /* Build configuration list for PBXNativeTarget "MagicCounterTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2DB9B9A2EE4DD5100372366 /* Debug */,
D2DB9B9B2EE4DD5100372366 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D2DB9B9C2EE4DD5100372366 /* Build configuration list for PBXNativeTarget "MagicCounterUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2DB9B9D2EE4DD5100372366 /* Debug */,
D2DB9B9E2EE4DD5100372366 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = D2DB9B6B2EE4DD5000372366 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>MagicCounter.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,131 @@
//
// Components.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import SwiftUI
/**
* A circular button with an icon, used for game controls.
*/
struct CircleButton: View {
let icon: String
let color: Color
let size: CGFloat
let action: () -> Void
init(icon: String, color: Color, size: CGFloat = 44, action: @escaping () -> Void) {
self.icon = icon
self.color = color
self.size = size
self.action = action
}
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(size < 40 ? .caption : .title3)
.frame(width: size, height: size)
.background(color.opacity(0.2))
.clipShape(Circle())
.foregroundStyle(color)
}
}
}
/**
* A control for adjusting a large numerical value (like Life).
*/
struct LifeCounterControl: View {
let value: Int
let onDecrease: () -> Void
let onIncrease: () -> Void
var body: some View {
HStack(spacing: 16) {
CircleButton(icon: "minus", color: .red, action: onDecrease)
Text("\(value)")
.font(.system(size: 56, weight: .bold, design: .rounded))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(.primary)
CircleButton(icon: "plus", color: .green, action: onIncrease)
}
.padding(.horizontal, 16)
}
}
/**
* A smaller control for adjusting secondary values (like Poison).
*/
struct SmallCounterControl: View {
let value: Int
let icon: String
let color: Color
let onDecrease: () -> Void
let onIncrease: () -> Void
var body: some View {
HStack(spacing: 4) {
CircleButton(icon: "minus", color: .gray, size: 24, action: onDecrease)
VStack(spacing: 0) {
Image(systemName: icon)
.font(.caption2)
.foregroundStyle(color)
Text("\(value)")
.font(.title3.bold())
.foregroundStyle(color)
}
.frame(minWidth: 30)
CircleButton(icon: "plus", color: .gray, size: 24, action: onIncrease)
}
.padding(6)
.background(color.opacity(0.1))
.cornerRadius(12)
}
}
/**
* A reusable slider row for settings.
*/
struct SettingSlider: View {
let title: String
let value: Binding<Double>
let range: ClosedRange<Double>
let step: Double
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(title)
Spacer()
Text("\(Int(value.wrappedValue))")
.bold()
}
Slider(value: value, in: range, step: step)
}
}
}
/**
* Helper for haptic feedback.
*/
enum Haptics {
static func play(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
guard UserDefaults.standard.bool(forKey: "hapticFeedbackEnabled") else { return }
let generator = UIImpactFeedbackGenerator(style: style)
generator.impactOccurred()
}
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
guard UserDefaults.standard.bool(forKey: "hapticFeedbackEnabled") else { return }
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(type)
}
}

View File

@@ -0,0 +1,129 @@
//
// ContentView.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject var gameManager: GameManager
@State private var showSetup = false
@AppStorage("accentColorName") private var accentColorName = "Blue"
var selectedColor: Color {
switch accentColorName {
case "Blue": return .blue
case "Purple": return .purple
case "Pink": return .pink
case "Red": return .red
case "Orange": return .orange
case "Green": return .green
case "Teal": return .teal
case "Indigo": return .indigo
case "Mint": return .mint
case "Brown": return .brown
case "Cyan": return .cyan
default: return .blue
}
}
var body: some View {
Group {
if let activeMatch = gameManager.activeMatch {
GameView(match: activeMatch)
.transition(.move(edge: .bottom))
} else {
TabView {
NavigationStack {
List {
if gameManager.matchHistory.isEmpty {
ContentUnavailableView("No Matches", systemImage: "gamecontroller", description: Text("Start a new game to begin tracking."))
} else {
ForEach(gameManager.matchHistory) { match in
MatchHistoryRow(match: match)
.contentShape(Rectangle()) // Ensure the whole row is tappable
.onTapGesture {
withAnimation {
gameManager.resumeMatch(match)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
withAnimation {
gameManager.deleteMatch(id: match.id)
}
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Games")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showSetup = true }) {
Image(systemName: "plus")
}
}
}
}
.tabItem {
Label("Games", systemImage: "clock.fill")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
}
}
.tint(selectedColor)
.sheet(isPresented: $showSetup) {
SetupView()
.tint(selectedColor)
}
}
}
struct MatchHistoryRow: View {
let match: MatchRecord
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(match.name)
.font(.headline)
.foregroundStyle(.primary)
Text(match.startedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if match.state.stopped {
Text("Stopped")
.font(.caption)
.foregroundStyle(.secondary)
} else if let winner = match.state.winner {
Text("Winner: \(winner.name)")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Ongoing")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green.opacity(0.2))
.foregroundStyle(.green)
.cornerRadius(8)
}
}
.padding(.vertical, 8)
}
}

View File

@@ -0,0 +1,137 @@
//
// GameManager.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import Foundation
import SwiftUI
import Combine
/**
* Manages the global game state and match history.
*
* - matchHistory: List of all past and current matches.
* - activeMatch: The currently active match, if any.
*/
@MainActor
final class GameManager: ObservableObject {
@Published var matchHistory: [MatchRecord] = []
@Published var activeMatch: MatchRecord?
private let historyKey = "match_history_json"
private let saveSubject = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
nonisolated init() {
Task { @MainActor in
self.loadHistory()
self.setupAutoSave()
}
}
private func setupAutoSave() {
saveSubject
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.saveHistory()
}
.store(in: &cancellables)
}
func startNewGame(players: [String], startingLife: Int, trackPoison: Bool, trackCommander: Bool, matchName: String) {
let playerStates = players.enumerated().map { (index, name) in
PlayerState(
id: index,
name: name,
life: startingLife,
poison: 0,
commanderDamages: [:],
scooped: false
)
}
let gameState = GameState(
players: playerStates,
startingLife: startingLife,
trackPoison: trackPoison,
trackCommanderDamage: trackCommander,
stopped: false
)
let newMatch = MatchRecord(
id: UUID().uuidString,
name: matchName.isEmpty ? "Match \(Date().formatted())" : matchName,
startedAt: Date(),
lastUpdated: Date(),
ongoing: true,
winnerPlayerId: nil,
state: gameState
)
activeMatch = newMatch
matchHistory.insert(newMatch, at: 0)
saveHistory()
}
func updateActiveGame(state: GameState) {
guard var match = activeMatch else { return }
match.state = state
match.lastUpdated = Date()
if state.stopped {
match.ongoing = false
} else if let winner = state.winner {
match.ongoing = false
match.winnerPlayerId = winner.id
} else {
match.ongoing = true
match.winnerPlayerId = nil
}
activeMatch = match
if let index = matchHistory.firstIndex(where: { $0.id == match.id }) {
matchHistory[index] = match
}
saveSubject.send()
}
func stopGame() {
guard var match = activeMatch else { return }
match.ongoing = false
match.state.stopped = true
activeMatch = nil
if let index = matchHistory.firstIndex(where: { $0.id == match.id }) {
matchHistory[index] = match
}
saveHistory()
}
func deleteMatch(id: String) {
if activeMatch?.id == id {
activeMatch = nil
}
matchHistory.removeAll { $0.id == id }
saveHistory()
}
func resumeMatch(_ match: MatchRecord) {
activeMatch = match
}
private func loadHistory() {
if let data = UserDefaults.standard.data(forKey: historyKey) {
if let decoded = try? JSONDecoder().decode([MatchRecord].self, from: data) {
matchHistory = decoded
}
}
}
private func saveHistory() {
if let encoded = try? JSONEncoder().encode(matchHistory) {
UserDefaults.standard.set(encoded, forKey: historyKey)
}
}
}

View File

@@ -0,0 +1,359 @@
//
// GameView.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import SwiftUI
/**
* Main game screen displaying the grid of players.
*
* - match: The match record to initialize the game state from.
*/
struct GameView: View {
@EnvironmentObject var gameManager: GameManager
@State private var gameState: GameState
@State private var showingStopConfirmation = false
@State private var selectedPlayerForCommander: PlayerState?
init(match: MatchRecord) {
self._gameState = State(initialValue: match.state)
}
var body: some View {
ZStack {
// Background
LinearGradient(colors: [Color.black, Color.indigo.opacity(0.5)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 0) {
// Custom Toolbar
HStack {
Button(action: { gameManager.activeMatch = nil }) {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.padding(10)
.background(.ultraThinMaterial)
.clipShape(Circle())
}
Spacer()
if gameState.stopped {
Text("Game Stopped")
.font(.headline)
.foregroundStyle(.red)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.cornerRadius(8)
} else if let winner = gameState.winner {
Text("Winner: \(winner.name)")
.font(.headline)
.foregroundStyle(.green)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.cornerRadius(8)
} else {
Text("Magic Counter")
.font(.headline)
.foregroundStyle(.white)
}
Spacer()
if !gameState.stopped && gameState.winner == nil {
Button(action: { showingStopConfirmation = true }) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.padding(10)
.background(.ultraThinMaterial)
.clipShape(Circle())
}
} else {
Color.clear.frame(width: 40, height: 40)
}
}
.padding()
.background(.ultraThinMaterial)
// Players Grid
GeometryReader { geometry in
let columns = [GridItem(.adaptive(minimum: 320), spacing: 24)]
ScrollView {
LazyVGrid(columns: columns, spacing: 24) {
ForEach(gameState.players) { player in
PlayerCell(
player: player,
gameState: gameState,
isWinner: gameState.winner?.id == player.id,
onUpdate: updatePlayer,
onCommanderTap: { selectedPlayerForCommander = player },
onScoop: { scoopPlayer(player) }
)
.frame(height: 260)
}
}
.padding(24)
}
}
}
}
.alert("Stop Game?", isPresented: $showingStopConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Stop", role: .destructive) {
gameManager.stopGame()
gameState.stopped = true
}
} message: {
Text("Are you sure you want to stop this game?")
}
.sheet(item: $selectedPlayerForCommander) { player in
CommanderDamageView(
targetPlayer: player,
gameState: gameState,
onUpdate: { updatedPlayer in
updatePlayer(player: updatedPlayer)
}
)
.presentationDetents([.medium])
}
.onChange(of: gameState) { newState in
if newState.winner != nil {
Haptics.notification(.success)
}
gameManager.updateActiveGame(state: newState)
}
}
private func updatePlayer(player: PlayerState) {
if gameState.stopped || gameState.winner != nil { return }
if let index = gameState.players.firstIndex(where: { $0.id == player.id }) {
gameState.players[index] = player
}
// Update the sheet state if this is the player being edited to ensure the view refreshes
if selectedPlayerForCommander?.id == player.id {
selectedPlayerForCommander = player
}
}
private func scoopPlayer(_ player: PlayerState) {
if gameState.stopped || gameState.winner != nil { return }
var newPlayer = player
newPlayer.scooped = true
updatePlayer(player: newPlayer)
}
}
/**
* Individual player cell component.
*
* - player: The player state to display.
* - gameState: The global game state.
* - isWinner: Whether this player is the winner.
* - onUpdate: Callback to update the player state.
* - onCommanderTap: Callback when commander damage button is tapped.
* - onScoop: Callback when scoop action is triggered.
*/
struct PlayerCell: View {
let player: PlayerState
let gameState: GameState
let isWinner: Bool
let onUpdate: (PlayerState) -> Void
let onCommanderTap: () -> Void
let onScoop: () -> Void
@State private var showScoopConfirmation = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 24)
.fill(.ultraThinMaterial)
.shadow(color: isWinner ? .green.opacity(0.5) : .black.opacity(0.2), radius: isWinner ? 20 : 10, x: 0, y: 5)
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(isWinner ? Color.green : Color.clear, lineWidth: 3)
)
VStack(spacing: 12) {
// Header
HStack {
Text(player.name)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.8)
.foregroundStyle(.primary)
Spacer()
Button(action: { showScoopConfirmation = true }) {
Image(systemName: "flag.fill")
.foregroundStyle(.secondary)
.padding(4)
}
}
.padding(.horizontal)
.padding(.top, 12)
// Life
LifeCounterControl(
value: player.life,
onDecrease: { adjustLife(by: -1) },
onIncrease: { adjustLife(by: 1) }
)
Spacer()
// Counters Row
HStack(spacing: 12) {
if gameState.trackPoison {
SmallCounterControl(
value: player.poison,
icon: "drop.fill",
color: .purple,
onDecrease: { adjustPoison(by: -1) },
onIncrease: { adjustPoison(by: 1) }
)
}
if gameState.trackCommanderDamage {
Button(action: onCommanderTap) {
VStack(spacing: 2) {
Image(systemName: "shield.fill")
.font(.caption)
Text("CMD")
.font(.caption2.bold())
}
.frame(width: 44, height: 44)
.background(Color.orange.opacity(0.1))
.foregroundStyle(.orange)
.clipShape(Circle())
}
}
}
.padding(.bottom, 16)
}
if player.isEliminated && !isWinner {
ZStack {
Color.black.opacity(0.6)
.cornerRadius(24)
Image(systemName: "xmark")
.font(.system(size: 60, weight: .bold))
.foregroundStyle(.white.opacity(0.8))
}
}
}
.opacity(player.isEliminated && !isWinner ? 0.8 : 1)
.scaleEffect(isWinner ? 1.05 : 1)
.animation(.spring, value: isWinner)
.alert("Scoop?", isPresented: $showScoopConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Scoop", role: .destructive) {
Haptics.notification(.warning)
onScoop()
}
} message: {
Text("Are you sure you want to scoop?")
}
}
private func adjustLife(by amount: Int) {
if player.isEliminated { return }
Haptics.play(.light)
var newPlayer = player
newPlayer.life += amount
onUpdate(newPlayer)
if newPlayer.isEliminated && !player.isEliminated {
Haptics.notification(.error)
}
}
private func adjustPoison(by amount: Int) {
if player.isEliminated { return }
Haptics.play(.light)
var newPlayer = player
newPlayer.poison = max(0, newPlayer.poison + amount)
onUpdate(newPlayer)
if newPlayer.isEliminated && !player.isEliminated {
Haptics.notification(.error)
}
}
}
/**
* Sheet for managing commander damage received by a player.
*
* - targetPlayer: The player receiving damage.
* - gameState: The global game state.
* - onUpdate: Callback to update the player state.
*/
struct CommanderDamageView: View {
@Environment(\.dismiss) var dismiss
let targetPlayer: PlayerState
let gameState: GameState
let onUpdate: (PlayerState) -> Void
var body: some View {
NavigationStack {
List {
ForEach(gameState.players.filter { $0.id != targetPlayer.id }) { attacker in
HStack {
Text(attacker.name)
.font(.headline)
Spacer()
HStack(spacing: 16) {
CircleButton(
icon: "minus",
color: .secondary,
action: { adjustCommanderDamage(attackerId: attacker.id, by: -1) }
)
Text("\(targetPlayer.commanderDamages[attacker.id] ?? 0)")
.font(.title.bold())
.frame(minWidth: 40)
.multilineTextAlignment(.center)
CircleButton(
icon: "plus",
color: .primary,
action: { adjustCommanderDamage(attackerId: attacker.id, by: 1) }
)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Commander Damage to \(targetPlayer.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func adjustCommanderDamage(attackerId: Int, by amount: Int) {
Haptics.play(.light)
var newPlayer = targetPlayer
var damages = newPlayer.commanderDamages
let current = damages[attackerId] ?? 0
damages[attackerId] = max(0, current + amount)
newPlayer.commanderDamages = damages
onUpdate(newPlayer)
if newPlayer.isEliminated && !targetPlayer.isEliminated {
Haptics.notification(.error)
}
}
}

View File

@@ -0,0 +1,18 @@
//
// Item.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,37 @@
{
"color-space-for-untagged-svg-colors" : "display-p3",
"fill" : {
"automatic-gradient" : "display-p3:0.20140,0.16683,0.29537,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "logo 2.png",
"name" : "logo 2",
"position" : {
"scale" : 0.85,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -0,0 +1,27 @@
//
// MagicCounterApp.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import SwiftUI
@main
struct MagicCounterApp: App {
@StateObject private var gameManager = GameManager()
init() {
UserDefaults.standard.register(defaults: [
"hapticFeedbackEnabled": true
])
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(gameManager)
.preferredColorScheme(.dark) // Force dark mode for that "Liquid Glass" feel usually looks better, or let it adapt.
}
}
}

View File

@@ -0,0 +1,50 @@
//
// Models.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import Foundation
// A single player's state
struct PlayerState: Codable, Identifiable, Equatable {
var id: Int
var name: String
var life: Int
var poison: Int
var commanderDamages: [Int: Int] // attackerId: damage
var scooped: Bool
var isEliminated: Bool {
life <= 0 || scooped || commanderDamages.values.contains { $0 >= 21 } || poison >= 10
}
}
// The full game state
struct GameState: Codable, Equatable {
var players: [PlayerState]
var startingLife: Int
var trackPoison: Bool
var trackCommanderDamage: Bool
var stopped: Bool
var winner: PlayerState? {
let activePlayers = players.filter { !$0.isEliminated }
if activePlayers.count == 1 {
return activePlayers.first
}
return nil
}
}
// A record of a match
struct MatchRecord: Codable, Identifiable, Equatable {
var id: String
var name: String
var startedAt: Date
var lastUpdated: Date
var ongoing: Bool
var winnerPlayerId: Int?
var state: GameState
}

View File

@@ -0,0 +1,104 @@
//
// SettingsView.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import SwiftUI
struct SettingsView: View {
@AppStorage("accentColorName") private var accentColorName = "Blue"
@AppStorage("hapticFeedbackEnabled") private var hapticFeedbackEnabled = true
private let colors: [(name: String, color: Color)] = [
("Blue", .blue),
("Purple", .purple),
("Pink", .pink),
("Red", .red),
("Orange", .orange),
("Green", .green),
("Teal", .teal),
("Indigo", .indigo),
("Mint", .mint),
("Brown", .brown),
("Cyan", .cyan)
]
private var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
private func foregroundColor(for colorName: String) -> Color {
switch colorName {
case "Mint", "Cyan", "Yellow":
return .black
default:
return .white
}
}
var body: some View {
NavigationStack {
Form {
Section("General") {
Toggle("Haptic Feedback", isOn: $hapticFeedbackEnabled)
}
Section("Appearance") {
VStack(alignment: .leading, spacing: 16) {
Text("ACCENT COLOR")
.font(.caption)
.foregroundStyle(.secondary)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 44))], spacing: 12) {
ForEach(colors, id: \.name) { item in
Circle()
.fill(item.color)
.frame(width: 44, height: 44)
.overlay {
if accentColorName == item.name {
Image(systemName: "checkmark")
.font(.headline)
.foregroundStyle(foregroundColor(for: item.name))
}
}
.onTapGesture {
accentColorName = item.name
}
}
}
Divider()
Button("Reset to Default") {
accentColorName = "Blue"
}
.foregroundStyle(.red)
}
.padding(.vertical, 8)
}
Section("About") {
HStack {
Text("App Name")
Spacer()
Text("Magic Counter")
.foregroundStyle(.secondary)
}
HStack {
Text("Version")
Spacer()
Text("\(currentVersion) (\(buildNumber))")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
}
}
}

View File

@@ -0,0 +1,100 @@
//
// SetupView.swift
// MagicCounter
//
// Created by Atridad Lahiji on 2025-12-06.
//
import SwiftUI
/**
* Screen for configuring a new game.
*
* Allows setting player count, starting life, and game options.
*/
struct SetupView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var gameManager: GameManager
@State private var playerCount: Double = 4
@State private var startingLife: Double = 40
@State private var trackPoison = true
@State private var trackCommander = true
@State private var matchName = ""
@State private var playerNames: [String] = []
var body: some View {
NavigationStack {
Form {
Section("Game Settings") {
SettingSlider(
title: "Starting Life",
value: $startingLife,
range: 10...40,
step: 5
)
SettingSlider(
title: "Players",
value: $playerCount,
range: 2...8,
step: 1
)
.onChange(of: playerCount) { newValue in
updatePlayerNames(count: Int(newValue))
}
}
Section("Options") {
Toggle("Track Poison", isOn: $trackPoison)
Toggle("Track Commander Damage", isOn: $trackCommander)
TextField("Match Name (Optional)", text: $matchName)
}
Section("Player Names") {
ForEach(0..<playerNames.count, id: \.self) { index in
TextField("Player \(index + 1)", text: $playerNames[index])
}
}
}
.navigationTitle("New Game")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Start") {
startGame()
}
.bold()
}
}
.onAppear {
updatePlayerNames(count: Int(playerCount))
}
}
}
private func updatePlayerNames(count: Int) {
while playerNames.count < count {
playerNames.append("Player \(playerNames.count + 1)")
}
while playerNames.count > count {
playerNames.removeLast()
}
}
private func startGame() {
gameManager.startNewGame(
players: playerNames,
startingLife: Int(startingLife),
trackPoison: trackPoison,
trackCommander: trackCommander,
matchName: matchName
)
dismiss()
}
}

View File

@@ -0,0 +1,17 @@
//
// MagicCounterTests.swift
// MagicCounterTests
//
// Created by Atridad Lahiji on 2025-12-06.
//
import Testing
@testable import MagicCounter
struct MagicCounterTests {
@Test func example() async throws {
// Todo: Add SOME form of test lol
}
}

View File

@@ -0,0 +1,31 @@
//
// MagicCounterUITests.swift
// MagicCounterUITests
//
// Created by Atridad Lahiji on 2025-12-06.
//
import XCTest
final class MagicCounterUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
override func tearDownWithError() throws {
}
@MainActor
func testExample() throws {
let app = XCUIApplication()
app.launch()
}
@MainActor
func testLaunchPerformance() throws {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View File

@@ -0,0 +1,30 @@
//
// MagicCounterUITestsLaunchTests.swift
// MagicCounterUITests
//
// Created by Atridad Lahiji on 2025-12-06.
//
import XCTest
final class MagicCounterUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB