Turned into a monorepo for iOS and Android

This commit is contained in:
2025-12-07 01:36:53 -07:00
parent dcb8b8401b
commit 9dea9f6efa
92 changed files with 1803 additions and 1 deletions

1
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,69 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.atridad.magiccounter"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.magiccounter"
minSdk = 31
targetSdk = 36
versionCode = 2
versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
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
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.core.ktx)
implementation(libs.material)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.ktorx.serialization.json)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package com.atridad.magiccounter
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.atridad.magiccounter", appContext.packageName)
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MagicCounter"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.MagicCounter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,24 @@
package com.atridad.magiccounter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import com.atridad.magiccounter.ui.MagicCounterApp
import com.atridad.magiccounter.ui.theme.MagicCounterTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MagicCounterTheme {
Surface(color = MaterialTheme.colorScheme.background) {
MagicCounterApp()
}
}
}
}
}

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

@@ -0,0 +1,430 @@
package com.atridad.magiccounter.ui.screens
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
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.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
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
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 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,
onProgress: ((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 commanderDamages = remember { mutableStateMapOf<Int, MutableMap<Int, Int>>() }
val eliminated = remember { mutableStateMapOf<Int, Boolean>() }
// 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)
commanderDamages.putIfAbsent(p.id, p.commanderDamages.toMutableMap())
eliminated.putIfAbsent(p.id, p.scooped)
}
fun checkElimination(playerId: Int) {
val life = lifeTotals[playerId] ?: state.startingLife
if (life <= 0) {
eliminated[playerId] = true
return
}
val fromMap: Map<Int, Int> = commanderDamages[playerId] ?: emptyMap()
if (fromMap.values.any { it >= 21 }) {
eliminated[playerId] = true
}
}
fun snapshotState(): GameState = state.copy(
players = state.players.map { p ->
PlayerState(
id = p.id,
name = p.name,
life = lifeTotals[p.id] ?: p.life,
poison = poisonTotals[p.id] ?: p.poison,
commanderDamages = commanderDamages[p.id]?.toMap() ?: p.commanderDamages,
scooped = eliminated[p.id] == true
)
}
)
Column(modifier = modifier.padding(12.dp)) {
// 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(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(displayPlayers, key = { _, item -> item.id }) { index, player ->
val accent = seatAccentColor(index, MaterialTheme.colorScheme)
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.value) return@PlayerCard
lifeTotals[player.id] = new
checkElimination(player.id)
onProgress?.invoke(snapshotState())
},
poison = poisonTotals[player.id] ?: 0,
onPoisonChange = {
if (!gameLocked.value) {
poisonTotals[player.id] = it
onProgress?.invoke(snapshotState())
}
},
trackPoison = state.trackPoison,
trackCommanderDamage = state.trackCommanderDamage,
commanderDamages = perPlayerCommander,
onCommanderDamageChange = { fromId, dmg ->
if (!gameLocked.value) {
val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap()
newMap[fromId] = dmg
commanderDamages[player.id] = newMap
checkElimination(player.id)
onProgress?.invoke(snapshotState())
}
},
rotation = 0f,
accentColor = accent,
isEliminated = eliminated[player.id] == true,
isWinner = currentWinnerId == player.id,
onScoop = {
if (!gameLocked.value) {
eliminated[player.id] = true
onProgress?.invoke(snapshotState())
}
}
)
}
}
// 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") }
}
)
}
}
}
private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.ColorScheme): Color = when (index % 6) {
0 -> scheme.primary
1 -> scheme.tertiary
2 -> scheme.secondary
3 -> scheme.error
4 -> scheme.primaryContainer
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,
trackPoison: 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
.padding(4.dp)
.fillMaxWidth()
.graphicsLayer { rotationZ = rotation },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(containerColor = containerColor.value),
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 = 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 = controlsEnabled)
if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange, enabled = controlsEnabled)
if (trackCommanderDamage) {
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 = controlsEnabled)
}
}
}
}
if (isEliminated) {
// Skull overlay
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
)
}
}
}
}
}
/**
* 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,
enabled: Boolean = true
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label)
Row(verticalAlignment = Alignment.CenterVertically) {
CounterIconButton(Icons.Default.Remove, "decrement", enabled = enabled) { onChange(value - 1) }
Text(
text = value.toString(),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 12.dp),
textAlign = TextAlign.Center
)
CounterIconButton(Icons.Default.Add, "increment", enabled = enabled) { onChange(value + 1) }
}
}
}
/**
* 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(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Life", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
CounterIconButton(Icons.Default.Remove, "decrement life", enabled = enabled) { onChange(value - 1) }
Text(
text = value.toString(),
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center
)
CounterIconButton(Icons.Default.Add, "increment life", enabled = enabled) { onChange(value + 1) }
}
}
}
/**
* 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)) {
Icon(icon, contentDescription = contentDescription)
}
}

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

@@ -0,0 +1,70 @@
package com.atridad.magiccounter.ui.settings
import android.content.Context
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.json.Json
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
@Serializable
data class MatchRecord(
val id: String,
val name: String,
val startedAtEpochMs: Long,
val lastUpdatedEpochMs: Long,
val ongoing: Boolean,
val winnerPlayerId: Int? = null,
val state: com.atridad.magiccounter.ui.state.GameState
)
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")
fun themeMode(context: Context): Flow<ThemeMode> =
context.dataStore.data.map { prefs ->
when (prefs[THEME_MODE]) {
1 -> ThemeMode.Light
2 -> ThemeMode.Dark
else -> ThemeMode.System
}
}
suspend fun setThemeMode(context: Context, mode: ThemeMode) {
context.dataStore.edit { prefs ->
prefs[THEME_MODE] = when (mode) {
ThemeMode.System -> 0
ThemeMode.Light -> 1
ThemeMode.Dark -> 2
}
}
}
// 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())
} ?: emptyList()
}
suspend fun writeMatchHistory(context: Context, history: List<MatchRecord>) {
val jsonStr = json.encodeToString(history)
context.dataStore.edit { prefs ->
prefs[MATCH_HISTORY] = jsonStr
}
}
}

View File

@@ -0,0 +1,39 @@
package com.atridad.magiccounter.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
val themeMode: StateFlow<ThemeMode> =
AppSettingsRepository.themeMode(application).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ThemeMode.System
)
fun setTheme(mode: ThemeMode) {
viewModelScope.launch {
AppSettingsRepository.setThemeMode(getApplication(), mode)
}
}
val matchHistory: StateFlow<List<MatchRecord>> =
AppSettingsRepository.readMatchHistory(application).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun saveHistory(history: List<MatchRecord>) {
viewModelScope.launch {
AppSettingsRepository.writeMatchHistory(getApplication(), history)
}
}
}

View File

@@ -0,0 +1,29 @@
package com.atridad.magiccounter.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
@Immutable
@Serializable
data class PlayerState(
val id: Int,
val name: String,
val life: Int,
val poison: Int,
val commanderDamages: Map<Int, Int>,
val scooped: Boolean = false
)
@Immutable
@Serializable
data class GameState(
val players: List<PlayerState>,
val startingLife: Int,
val trackPoison: 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

@@ -0,0 +1,45 @@
package com.atridad.magiccounter.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.atridad.magiccounter.ui.settings.ThemeMode
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun MagicCounterTheme(
themeMode: ThemeMode = ThemeMode.System,
useDarkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val dark = when (themeMode) {
ThemeMode.System -> useDarkTheme
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
dark -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,7 @@
package com.atridad.magiccounter.ui.theme
import androidx.compose.material3.Typography
val Typography = Typography()

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Inset the bitmap to avoid clipping on circular masks -->
<item
android:top="18dp"
android:bottom="18dp"
android:left="18dp"
android:right="18dp">
<bitmap
android:src="@drawable/ic_launcher"
android:gravity="center" />
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M22,16V4c0,-1.1 -0.9,-2 -2,-2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zm-11,-4l2.03,2.71L16,11l4,5H8l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2H4V6H2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16,-4H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zm-8,12.5v-9l6,4.5 -6,4.5z" />
</vector>

View File

@@ -0,0 +1,9 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="135"
android:centerColor="#009688"
android:endColor="#00695C"
android:startColor="#4DB6AC"
android:type="linear" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/transparent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/transparent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<style name="Theme.MagicCounter" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,8 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="nav_header_vertical_spacing">8dp</dimen>
<dimen name="nav_header_height">176dp</dimen>
<dimen name="fab_margin">16dp</dimen>
</resources>

View File

@@ -0,0 +1,13 @@
<resources>
<string name="app_name">MagicCounter</string>
<string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string>
<string name="nav_header_title">Android Studio</string>
<string name="nav_header_subtitle">android.studio@android.com</string>
<string name="nav_header_desc">Navigation header</string>
<string name="action_settings">Settings</string>
<string name="menu_home">Home</string>
<string name="menu_gallery">Gallery</string>
<string name="menu_slideshow">Slideshow</string>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<style name="Theme.MagicCounter" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package com.atridad.magiccounter
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}