16 Commits

95 changed files with 2990 additions and 466 deletions

View File

@@ -1,2 +1,25 @@
# MagicCounter
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
### Android
You have two options:
1. Download the latest APK from the Released page
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
- Android 15+
- iOS 17+
## 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

@@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
release/

View File

6
android/.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>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="17" />
</component>
</project>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<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" />
</component>
<component name="ProjectType">

View File

@@ -7,34 +7,43 @@ plugins {
android {
namespace = "com.atridad.magiccounter"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.magiccounter"
minSdk = 35
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
minSdk = 31
targetSdk = 36
versionCode = 2
versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures {
compose = true
}

View File

@@ -0,0 +1,487 @@
package com.atridad.magiccounter.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import kotlinx.coroutines.delay
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.screens.GameScreen
import com.atridad.magiccounter.ui.screens.SetupScreen
import com.atridad.magiccounter.ui.state.GameState
import androidx.lifecycle.viewmodel.compose.viewModel
import com.atridad.magiccounter.ui.settings.AppSettingsViewModel
import com.atridad.magiccounter.ui.settings.ThemeMode
import com.atridad.magiccounter.ui.settings.MatchRecord
import com.atridad.magiccounter.ui.theme.MagicCounterTheme
import com.atridad.magiccounter.ui.theme.CustomIcons
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
private sealed class Screen {
data object Home : Screen()
data object Setup : Screen()
data object Settings : Screen()
data class Game(val matchId: String, val bootState: GameState? = null) : Screen()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MagicCounterApp() {
val screenStack: SnapshotStateList<Screen> = remember { mutableStateListOf(Screen.Home) }
val settingsVm: AppSettingsViewModel = viewModel()
val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState()
MagicCounterTheme(themeMode = theme.value) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
navigationIcon = {
if (screenStack.size > 1) {
IconButton(onClick = { screenStack.removeAt(screenStack.lastIndex) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
IconButton(onClick = { screenStack.add(Screen.Settings) }) {
Icon(Icons.Default.Settings, contentDescription = "App settings")
}
}
)
},
floatingActionButton = {
// Show FAB only on home screen when no active game is running
val currentScreen = screenStack.last()
if (currentScreen is Screen.Home && historyState.value.none { it.ongoing }) {
FloatingActionButton(
onClick = { screenStack.add(Screen.Setup) }
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Start new game")
}
}
}
) { paddingValues ->
val currentScreen = screenStack.last()
BackHandler(enabled = screenStack.size > 1) { screenStack.removeAt(screenStack.lastIndex) }
when (currentScreen) {
is Screen.Home -> HomeScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
.fillMaxSize(),
history = historyState.value,
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 ->
val newList = historyState.value.filterNot { it.id == id }
settingsVm.saveHistory(newList)
}
)
is Screen.Setup -> SetupScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
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
val newId = java.util.UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val record = MatchRecord(
id = newId,
name = name,
startedAtEpochMs = now,
lastUpdatedEpochMs = now,
ongoing = true,
winnerPlayerId = null,
state = state
)
val updated = historyState.value.toMutableList().apply { add(0, record) }
settingsVm.saveHistory(updated)
// Replace Setup with the new Game screen (pop then push)
if (screenStack.isNotEmpty()) screenStack.removeAt(screenStack.lastIndex)
screenStack.add(Screen.Game(newId, state))
}
)
is Screen.Game -> {
val id = currentScreen.matchId
val bootState = currentScreen.bootState
val record = historyState.value.firstOrNull { it.id == id }
val stateForGame = record?.state ?: bootState
if (stateForGame == null) {
screenStack.removeAt(screenStack.lastIndex)
} else {
GameScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
state = stateForGame,
onProgress = { updated ->
val current = historyState.value
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(state = updated, lastUpdatedEpochMs = System.currentTimeMillis())
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
},
onWinner = { winnerId, finalState ->
val current = historyState.value
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(
state = finalState,
lastUpdatedEpochMs = System.currentTimeMillis(),
ongoing = false,
winnerPlayerId = winnerId
)
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
},
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)
}
)
}
}
is Screen.Settings -> SettingsContent(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
current = theme.value,
onSelect = { settingsVm.setTheme(it) }
)
}
}
}
}
@Composable
private fun SettingsContent(modifier: Modifier, current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
"Appearance",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Theme",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
ThemeMode.entries.forEach { mode ->
androidx.compose.material3.FilterChip(
selected = current == mode,
onClick = { onSelect(mode) },
label = { Text(mode.name) },
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
private fun HomeScreen(
modifier: Modifier,
history: List<MatchRecord>,
onResume: (MatchRecord) -> Unit,
onStopGame: (MatchRecord) -> Unit,
onDelete: (String) -> Unit
) {
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
if (history.isEmpty()) {
// Empty state
androidx.compose.foundation.layout.Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.History,
contentDescription = "Empty history",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"No games yet",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Start your first Magic: The Gathering game to begin tracking life totals and more.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
} else {
// 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)
)
}
}
}
}
}
// Past games section
val pastGames = history.filter { !it.ongoing }
items(pastGames, key = { it.id }) { rec ->
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
onClick = { onResume(rec) }
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
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(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
)
) {
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)
)
}
}
}
}
}
}
// Confirm delete dialog
val pending = history.firstOrNull { it.id == pendingDeleteId }
if (pendingDeleteId != null && pending != null) {
AlertDialog(
onDismissRequest = { pendingDeleteId = null },
title = { Text("Delete match?") },
text = { Text("Are you sure you want to delete \"${pending.name}\"?") },
confirmButton = {
TextButton(onClick = {
onDelete(pendingDeleteId!!)
pendingDeleteId = null
}) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { pendingDeleteId = null }) { Text("Cancel") } }
)
}
}
}
@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,18 +17,23 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
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.Remove
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,32 +46,43 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.atridad.magiccounter.ui.state.GameState
import com.atridad.magiccounter.ui.state.PlayerState
import java.util.concurrent.atomic.AtomicBoolean
import com.atridad.magiccounter.ui.theme.CustomIcons
@OptIn(ExperimentalFoundationApi::class)
@Composable
/**
* Game screen hosting all player panels.
*
* - state: immutable state used to bootstrap the in-memory counters
* - onProgress: called with a snapshot of the latest state after any user interaction
* - 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(
modifier: Modifier = Modifier,
state: GameState,
onEnd: () -> Unit,
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
val lifeTotals = remember { mutableStateMapOf<Int, Int>() }
val poisonTotals = remember { mutableStateMapOf<Int, Int>() }
val energyTotals = remember { mutableStateMapOf<Int, Int>() }
val experienceTotals = remember { mutableStateMapOf<Int, Int>() }
val commanderDamages = remember { mutableStateMapOf<Int, MutableMap<Int, Int>>() }
val eliminated = remember { mutableStateMapOf<Int, Boolean>() }
val gameLocked = remember { mutableStateMapOf<String, Boolean>() } // single key used to freeze when winner determined
// Tracks whether the game has ended. When true, inputs are frozen and the winner is highlighted.
val gameLocked = remember { mutableStateOf(false) }
// State for stop game confirmation
val showStopConfirm = remember { mutableStateOf(false) }
// Initialize
state.players.forEach { p ->
lifeTotals.putIfAbsent(p.id, p.life)
poisonTotals.putIfAbsent(p.id, p.poison)
energyTotals.putIfAbsent(p.id, p.energy)
experienceTotals.putIfAbsent(p.id, p.experience)
commanderDamages.putIfAbsent(p.id, p.commanderDamages.toMutableMap())
eliminated.putIfAbsent(p.id, p.scooped)
}
@@ -83,9 +99,6 @@ fun GameScreen(
}
}
// Single column for maximum width
val numColumns = 1
fun snapshotState(): GameState = state.copy(
players = state.players.map { p ->
PlayerState(
@@ -93,8 +106,6 @@ fun GameScreen(
name = p.name,
life = lifeTotals[p.id] ?: p.life,
poison = poisonTotals[p.id] ?: p.poison,
energy = energyTotals[p.id] ?: p.energy,
experience = experienceTotals[p.id] ?: p.experience,
commanderDamages = commanderDamages[p.id]?.toMap() ?: p.commanderDamages,
scooped = eliminated[p.id] == true
)
@@ -102,15 +113,60 @@ fun GameScreen(
)
Column(modifier = modifier.padding(12.dp)) {
// Lock game when only one active player remains
val aliveCount = state.players.count { eliminated[it.id] != true }
if (aliveCount == 1) {
val winnerId = state.players.first { eliminated[it.id] != true }.id
if (gameLocked["locked"] != true) {
gameLocked["locked"] = true
onWinner?.invoke(winnerId, snapshotState())
// 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 currentWinnerId: Int? = if (aliveCount == 1) {
state.players.first { eliminated[it.id] != true }.id
} else null
if (currentWinnerId != null && !gameLocked.value) {
gameLocked.value = true
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 }
LazyColumn(
@@ -123,42 +179,27 @@ fun GameScreen(
val perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap()
PlayerCard(
player = player,
gameState = state,
opponents = state.players.map { it.id }.filter { it != player.id },
life = lifeTotals[player.id] ?: state.startingLife,
onLifeChange = { new ->
if (gameLocked["locked"] == true) return@PlayerCard
if (gameLocked.value) return@PlayerCard
lifeTotals[player.id] = new
checkElimination(player.id)
onProgress?.invoke(snapshotState())
},
poison = poisonTotals[player.id] ?: 0,
onPoisonChange = {
if (gameLocked["locked"] != true) {
if (!gameLocked.value) {
poisonTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
energy = energyTotals[player.id] ?: 0,
onEnergyChange = {
if (gameLocked["locked"] != true) {
energyTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
experience = experienceTotals[player.id] ?: 0,
onExperienceChange = {
if (gameLocked["locked"] != true) {
experienceTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
trackPoison = state.trackPoison,
trackEnergy = state.trackEnergy,
trackExperience = state.trackExperience,
trackCommanderDamage = state.trackCommanderDamage,
commanderDamages = perPlayerCommander,
onCommanderDamageChange = { fromId, dmg ->
if (gameLocked["locked"] != true) {
if (!gameLocked.value) {
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
newMap[fromId] = dmg
commanderDamages[player.id] = newMap
@@ -169,8 +210,9 @@ fun GameScreen(
rotation = 0f,
accentColor = accent,
isEliminated = eliminated[player.id] == true,
isWinner = currentWinnerId == player.id,
onScoop = {
if (gameLocked["locked"] != true) {
if (!gameLocked.value) {
eliminated[player.id] = true
onProgress?.invoke(snapshotState())
}
@@ -178,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") }
}
)
}
}
}
@@ -190,32 +253,45 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color
else -> scheme.tertiaryContainer
}
/**
* Player panel with counters and actions.
*
* - player: immutable player data snapshot.
* - opponents: list of opponent player ids for commander damage rows.
* - life/poison: current counter values for this player.
* - onLifeChange/onPoisonChange: invoked with the new value when a counter is adjusted.
* - trackPoison/trackCommanderDamage: toggles for which rows are shown.
* - commanderDamages: map of damage received from opponent id -> damage.
* - onCommanderDamageChange: callback with (fromId, newDamage).
* - rotation: visual rotation of the card in degrees.
* - accentColor: seat accent color used for text and default border.
* - isEliminated: when true, card is dimmed and inputs disabled.
* - isWinner: when true, card is highlighted in green and inputs disabled.
* - onScoop: invoked when the player taps Scoop.
*/
@Composable
private fun PlayerCard(
player: PlayerState,
gameState: GameState,
opponents: List<Int>,
life: Int,
onLifeChange: (Int) -> Unit,
poison: Int,
onPoisonChange: (Int) -> Unit,
energy: Int,
onEnergyChange: (Int) -> Unit,
experience: Int,
onExperienceChange: (Int) -> Unit,
trackPoison: Boolean,
trackEnergy: Boolean,
trackExperience: Boolean,
trackCommanderDamage: Boolean,
commanderDamages: Map<Int, Int>,
onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit,
rotation: Float,
accentColor: Color,
isEliminated: Boolean,
isWinner: Boolean,
onScoop: () -> Unit
) {
val targetContainer = if (isEliminated) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant
val containerColor = animateColorAsState(targetValue = targetContainer, label = "eliminationColor")
val overlayAlpha = animateFloatAsState(targetValue = if (isEliminated) 1f else 0f, label = "overlayAlpha")
val controlsEnabled = !isEliminated && !isWinner
Card(
modifier = Modifier
@@ -224,31 +300,29 @@ private fun PlayerCard(
.graphicsLayer { rotationZ = rotation },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(containerColor = containerColor.value),
border = BorderStroke(2.dp, accentColor)
border = BorderStroke(3.dp, if (isWinner) Color(0xFF2E7D32) else accentColor)
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(12.dp).alpha(if (isEliminated) 0.45f else 1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(player.name, style = MaterialTheme.typography.titleMedium, color = accentColor)
TextButton(onClick = onScoop, enabled = !isEliminated) {
Text(player.name, style = MaterialTheme.typography.titleMedium, color = if (isWinner) Color(0xFF2E7D32) else accentColor)
TextButton(onClick = onScoop, enabled = controlsEnabled) {
Icon(Icons.Default.Flag, contentDescription = "Scoop")
Text("Scoop")
}
}
BigLifeRow(value = life, onChange = onLifeChange, enabled = !isEliminated)
BigLifeRow(value = life, onChange = onLifeChange, enabled = controlsEnabled)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = !isEliminated)
if (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange, enabled = !isEliminated)
if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange, enabled = !isEliminated)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = controlsEnabled)
if (trackCommanderDamage) {
Divider()
HorizontalDivider()
Text("Commander damage", style = MaterialTheme.typography.titleSmall)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
opponents.forEach { fromId ->
val value = commanderDamages[fromId] ?: 0
ChipRow(label = "From P${fromId + 1}", value = value, onChange = { onCommanderDamageChange(fromId, it) }, enabled = !isEliminated)
ChipRow(label = "From P${fromId + 1}", value = value, onChange = { onCommanderDamageChange(fromId, it) }, enabled = controlsEnabled)
}
}
}
@@ -256,26 +330,40 @@ private fun PlayerCard(
if (isEliminated) {
// Skull overlay
Text(
text = "☠️",
fontSize = 64.sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value)
)
Column(
modifier = Modifier.align(Alignment.Center).alpha(overlayAlpha.value),
horizontalAlignment = Alignment.CenterHorizontally
) {
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
)
}
}
}
}
}
private fun playerOpponents(selfId: Int, ids: Collection<Int>): List<Int> =
ids.filter { it != selfId }.sorted()
/**
* Counter row used for poison and commander damage.
*
* - label: row label text
* - value: current integer value
* - onChange: callback with the new value after +/- is pressed
* - enabled: when false, the +/- buttons are disabled
*/
@Composable
private fun ChipRow(
label: String,
value: Int,
onChange: (Int) -> Unit,
emphasized: Boolean = false,
enabled: Boolean = true
) {
Row(
@@ -297,6 +385,13 @@ private fun ChipRow(
}
}
/**
* Life total row with number and +/- buttons.
*
* - value: current life total
* - onChange: callback with new life total
* - enabled: when false, buttons are disabled
*/
@Composable
private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = true) {
Row(
@@ -318,6 +413,13 @@ private fun BigLifeRow(value: Int, onChange: (Int) -> Unit, enabled: Boolean = t
}
}
/**
* Icon button used for counters.
* - icon: vector asset to display
* - contentDescription: a11y description
* - enabled: controls click availability
* - onClick: invoked on press
*/
@Composable
private fun CounterIconButton(icon: ImageVector, contentDescription: String, enabled: Boolean = true, onClick: () -> Unit) {
FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {

View File

@@ -0,0 +1,191 @@
package com.atridad.magiccounter.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.state.GameState
import com.atridad.magiccounter.ui.state.PlayerState
import com.atridad.magiccounter.ui.state.defaultPlayerName
import kotlin.math.roundToInt
@Composable
fun SetupScreen(
modifier: Modifier = Modifier,
onStart: (String, GameState) -> Unit
) {
var playerCount by remember { mutableIntStateOf(4) }
var startingLife by remember { mutableIntStateOf(40) }
var trackPoison by remember { mutableStateOf(true) }
var trackCommander by remember { mutableStateOf(true) }
var matchName by remember { mutableStateOf("") }
val names = remember { mutableStateListOf<String>() }
LaunchedEffect(playerCount) {
while (names.size < playerCount) names.add(defaultPlayerName(names.size))
while (names.size > playerCount) names.removeAt(names.lastIndex)
}
Column(
modifier = modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Game Settings Section
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Game Settings",
style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
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

@@ -1,13 +1,11 @@
package com.atridad.magiccounter.ui.settings
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -29,6 +27,8 @@ enum class ThemeMode { System, Light, Dark }
private val Context.dataStore by preferencesDataStore(name = "app_settings")
object AppSettingsRepository {
// Use a tolerant JSON instance to handle backward/forward compatible schema changes
private val json = Json { ignoreUnknownKeys = true }
private val THEME_MODE = intPreferencesKey("theme_mode")
private val MATCH_HISTORY = stringPreferencesKey("match_history_json")
@@ -51,18 +51,18 @@ object AppSettingsRepository {
}
}
// Very small JSON blob to persist match history
// JSON blob to persist match history
fun readMatchHistory(context: Context): Flow<List<MatchRecord>> =
context.dataStore.data.map { prefs ->
prefs[MATCH_HISTORY]?.let {
runCatching { Json.decodeFromString<List<MatchRecord>>(it) }.getOrDefault(emptyList())
runCatching { json.decodeFromString<List<MatchRecord>>(it) }.getOrDefault(emptyList())
} ?: emptyList()
}
suspend fun writeMatchHistory(context: Context, history: List<MatchRecord>) {
val json = Json.encodeToString(history)
val jsonStr = json.encodeToString(history)
context.dataStore.edit { prefs ->
prefs[MATCH_HISTORY] = json
prefs[MATCH_HISTORY] = jsonStr
}
}
}

View File

@@ -3,7 +3,6 @@ package com.atridad.magiccounter.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn

View File

@@ -2,15 +2,6 @@ package com.atridad.magiccounter.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
@Immutable
@Serializable
data class CommanderDamage(
val fromPlayerId: Int,
val damage: Int
)
@Immutable
@Serializable
@@ -19,8 +10,6 @@ data class PlayerState(
val name: String,
val life: Int,
val poison: Int,
val energy: Int,
val experience: Int,
val commanderDamages: Map<Int, Int>,
val scooped: Boolean = false
)
@@ -31,9 +20,8 @@ data class GameState(
val players: List<PlayerState>,
val startingLife: Int,
val trackPoison: Boolean,
val trackEnergy: Boolean,
val trackExperience: Boolean,
val trackCommanderDamage: Boolean
val trackCommanderDamage: Boolean,
val stopped: Boolean = false
)
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

@@ -1,4 +1,3 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false

View File

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

View File

@@ -1,6 +1,6 @@
#Sat Aug 09 23:53:05 MDT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

View File

Binary file not shown.

View File

@@ -1,236 +0,0 @@
package com.atridad.magiccounter.ui
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.screens.GameScreen
import com.atridad.magiccounter.ui.screens.SetupScreen
import com.atridad.magiccounter.ui.state.GameState
import androidx.lifecycle.viewmodel.compose.viewModel
import com.atridad.magiccounter.ui.settings.AppSettingsViewModel
import com.atridad.magiccounter.ui.settings.ThemeMode
import com.atridad.magiccounter.ui.settings.MatchRecord
import com.atridad.magiccounter.ui.theme.MagicCounterTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.FilterChip
// no-op
// Top-level navigation destinations
private enum class Screen { Home, Setup, Game }
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MagicCounterApp() {
var gameState by remember { mutableStateOf<GameState?>(null) }
var showSettings by remember { mutableStateOf(false) }
var activeMatchId by remember { mutableStateOf<String?>(null) }
var screen by remember { mutableStateOf(Screen.Home) }
val settingsVm: AppSettingsViewModel = viewModel()
val theme = settingsVm.themeMode.collectAsState()
val historyState = settingsVm.matchHistory.collectAsState()
MagicCounterTheme(themeMode = theme.value) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) },
actions = {
if (screen != Screen.Home) {
IconButton(onClick = { screen = Screen.Home }) {
Icon(Icons.Default.Home, contentDescription = "Home")
}
}
IconButton(onClick = { showSettings = true }) {
Icon(Icons.Default.Settings, contentDescription = "App settings")
}
}
)
}
) { paddingValues ->
when (screen) {
Screen.Home -> HomeScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
history = historyState.value,
onNewGame = { screen = Screen.Setup },
onResume = { record ->
activeMatchId = record.id
gameState = record.state
screen = Screen.Game
}
)
Screen.Setup -> SetupScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
onStart = { name, state ->
// Create and persist a new MatchRecord
val newId = java.util.UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val record = MatchRecord(
id = newId,
name = name,
startedAtEpochMs = now,
lastUpdatedEpochMs = now,
ongoing = true,
winnerPlayerId = null,
state = state
)
val updated = historyState.value.toMutableList().apply { add(0, record) }
settingsVm.saveHistory(updated)
activeMatchId = newId
gameState = state
screen = Screen.Game
}
)
Screen.Game -> GameScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
state = gameState!!,
onEnd = { screen = Screen.Home },
onProgress = { updated ->
val current = historyState.value
val id = activeMatchId
if (id != null) {
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(state = updated, lastUpdatedEpochMs = System.currentTimeMillis())
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
}
},
onWinner = { winnerId, finalState ->
val current = historyState.value
val id = activeMatchId
if (id != null) {
val idx = current.indexOfFirst { it.id == id }
if (idx >= 0) {
val updatedRec = current[idx].copy(
state = finalState,
lastUpdatedEpochMs = System.currentTimeMillis(),
ongoing = false,
winnerPlayerId = winnerId
)
val newList = current.toMutableList().apply { set(idx, updatedRec) }
settingsVm.saveHistory(newList)
}
}
}
)
}
if (showSettings) {
ModalBottomSheet(onDismissRequest = { showSettings = false }) {
SettingsSheet(
current = theme.value,
onSelect = { settingsVm.setTheme(it) }
)
}
}
}
}
}
@Composable
private fun SettingsSheet(current: ThemeMode, onSelect: (ThemeMode) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Theme")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeMode.values().forEach { mode ->
FilterChip(
selected = current == mode,
onClick = { onSelect(mode) },
label = { Text(mode.name) }
)
}
}
}
}
@Composable
private fun HistorySheet(history: List<MatchRecord>, onResume: (MatchRecord) -> Unit) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Match history")
history.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val status = if (rec.ongoing) "Ongoing" else "Finished"
Text("$status • Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
rec.winnerPlayerId?.let { Text("Winner: Player ${it + 1}") }
}
IconButton(onClick = { onResume(rec) }) {
Icon(Icons.Default.Refresh, contentDescription = "Resume")
}
}
}
}
}
@Composable
private fun HomeScreen(
modifier: Modifier,
history: List<MatchRecord>,
onNewGame: () -> Unit,
onResume: (MatchRecord) -> Unit
) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNewGame) { Text("New game") }
Text("Ongoing")
history.filter { it.ongoing }.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
Text("Started ${java.text.DateFormat.getDateTimeInstance().format(java.util.Date(rec.startedAtEpochMs))}")
}
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Resume game") }
}
}
Text("Finished")
history.filter { !it.ongoing }.forEach { rec ->
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(rec.name)
val winner = rec.winnerPlayerId?.let { "Winner: Player ${it + 1}" } ?: ""
Text("Finished • $winner")
}
IconButton(onClick = { onResume(rec) }) { Icon(Icons.Default.Visibility, contentDescription = "View match") }
}
}
}
}

View File

@@ -1,132 +0,0 @@
package com.atridad.magiccounter.ui.screens
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.atridad.magiccounter.ui.state.GameState
import com.atridad.magiccounter.ui.state.PlayerState
import com.atridad.magiccounter.ui.state.defaultPlayerName
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun SetupScreen(
modifier: Modifier = Modifier,
onStart: (String, GameState) -> Unit
) {
var playerCount by remember { mutableIntStateOf(4) }
var startingLife by remember { mutableIntStateOf(40) }
var trackPoison by remember { mutableStateOf(true) }
var trackEnergy by remember { mutableStateOf(false) }
var trackExperience by remember { mutableStateOf(false) }
var trackCommander by remember { mutableStateOf(true) }
var matchName by remember { mutableStateOf("") }
val names = remember { mutableStateListOf<String>() }
LaunchedEffect(playerCount) {
while (names.size < playerCount) names.add(defaultPlayerName(names.size))
while (names.size > playerCount) names.removeLast()
}
Column(
modifier = modifier
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Starting life: $startingLife")
Slider(value = startingLife.toFloat(), onValueChange = { startingLife = it.toInt() }, valueRange = 20f..60f, steps = 20)
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")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackEnergy, onCheckedChange = { trackEnergy = it })
Text("Track energy")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = trackExperience, onCheckedChange = { trackExperience = it })
Text("Track experience")
}
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))
Button(onClick = {
val players = names.mapIndexed { index, name ->
PlayerState(
id = index,
name = name.ifBlank { defaultPlayerName(index) },
life = startingLife,
poison = 0,
energy = 0,
experience = 0,
commanderDamages = emptyMap()
)
}
onStart(
matchName.ifBlank { "Game ${System.currentTimeMillis()}" },
GameState(
players = players,
startingLife = startingLife,
trackPoison = trackPoison,
trackEnergy = trackEnergy,
trackExperience = trackExperience,
trackCommanderDamage = trackCommander
)
)
}) {
Text("Start game")
}
}
}

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