diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4856d3a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.atridad.magiccounter" + compileSdk = 35 + + defaultConfig { + applicationId = "com.atridad.magiccounter" + minSdk = 35 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "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) + debugImplementation(libs.androidx.compose.ui.tooling) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/atridad/magiccounter/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/atridad/magiccounter/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..8173b60 --- /dev/null +++ b/app/src/androidTest/java/com/atridad/magiccounter/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1a95ae2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/atridad/magiccounter/MainActivity.kt b/app/src/main/java/com/atridad/magiccounter/MainActivity.kt new file mode 100644 index 0000000..667a7de --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/MainActivity.kt @@ -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() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt new file mode 100644 index 0000000..935f96d --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/MagicCounterApp.kt @@ -0,0 +1,110 @@ +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.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.theme.MagicCounterTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.FilterChip +// no-op + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MagicCounterApp() { + var gameState by remember { mutableStateOf(null) } + var showSettings by remember { mutableStateOf(false) } + val settingsVm: AppSettingsViewModel = viewModel() + val theme = settingsVm.themeMode.collectAsState() + + MagicCounterTheme(themeMode = theme.value) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Magic Counter", style = MaterialTheme.typography.titleLarge) }, + actions = { + if (gameState != null) { + IconButton(onClick = { gameState = null }) { + Icon(Icons.Default.Refresh, contentDescription = "New game") + } + } + IconButton(onClick = { showSettings = true }) { + Icon(Icons.Default.Settings, contentDescription = "App settings") + } + } + ) + } + ) { paddingValues -> + if (gameState == null) { + SetupScreen( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + onStart = { state -> gameState = state } + ) + } else { + GameScreen( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = gameState!!, + onEnd = { gameState = null } + ) + } + 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) } + ) + } + } + } +} + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt new file mode 100644 index 0000000..22d8504 --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/GameScreen.kt @@ -0,0 +1,220 @@ +package com.atridad.magiccounter.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.BorderStroke +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.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.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 com.atridad.magiccounter.ui.state.GameState +import com.atridad.magiccounter.ui.state.PlayerState + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GameScreen( + modifier: Modifier = Modifier, + state: GameState, + onEnd: () -> Unit +) { + // Local editable state per player + val lifeTotals = remember { mutableStateMapOf() } + val poisonTotals = remember { mutableStateMapOf() } + val energyTotals = remember { mutableStateMapOf() } + val experienceTotals = remember { mutableStateMapOf() } + val commanderDamages = remember { mutableStateMapOf>() } + + // 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()) + } + + // Single column for maximum width per request + val numColumns = 1 + + Column(modifier = modifier.padding(12.dp)) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(state.players, key = { _, item -> item.id }) { index, player -> + val accent = seatAccentColor(index, MaterialTheme.colorScheme) + val perPlayerCommander: Map = commanderDamages[player.id]?.toMap() ?: emptyMap() + PlayerCard( + player = player, + opponents = state.players.map { it.id }.filter { it != player.id }, + life = lifeTotals[player.id] ?: state.startingLife, + onLifeChange = { lifeTotals[player.id] = it }, + poison = poisonTotals[player.id] ?: 0, + onPoisonChange = { poisonTotals[player.id] = it }, + energy = energyTotals[player.id] ?: 0, + onEnergyChange = { energyTotals[player.id] = it }, + experience = experienceTotals[player.id] ?: 0, + onExperienceChange = { experienceTotals[player.id] = it }, + trackPoison = state.trackPoison, + trackEnergy = state.trackEnergy, + trackExperience = state.trackExperience, + trackCommanderDamage = state.trackCommanderDamage, + commanderDamages = perPlayerCommander, + onCommanderDamageChange = { fromId, dmg -> + val newMap = (commanderDamages[player.id] ?: mutableMapOf()).toMutableMap() + newMap[fromId] = dmg + commanderDamages[player.id] = newMap + }, + rotation = 0f, + accentColor = accent + ) + } + } + } +} + +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 +} + +@Composable +private fun PlayerCard( + player: PlayerState, + opponents: List, + 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, + onCommanderDamageChange: (fromId: Int, damage: Int) -> Unit, + rotation: Float, + accentColor: Color +) { + Card( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .graphicsLayer { rotationZ = rotation }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + border = BorderStroke(2.dp, accentColor) + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(player.name, style = MaterialTheme.typography.titleMedium, color = accentColor) + + BigLifeRow(value = life, onChange = onLifeChange) + + if (trackPoison) ChipRow(label = "Poison", value = poison, onChange = onPoisonChange) + if (trackEnergy) ChipRow(label = "Energy", value = energy, onChange = onEnergyChange) + if (trackExperience) ChipRow(label = "Experience", value = experience, onChange = onExperienceChange) + + if (trackCommanderDamage) { + Divider() + 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) }) + } + } + } + } + } +} + +private fun playerOpponents(selfId: Int, ids: Collection): List = + ids.filter { it != selfId }.sorted() + +@Composable +private fun ChipRow( + label: String, + value: Int, + onChange: (Int) -> Unit, + emphasized: Boolean = false +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label) + Row(verticalAlignment = Alignment.CenterVertically) { + CounterIconButton(Icons.Default.Remove, "decrement") { 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") { onChange(value + 1) } + } + } +} + +@Composable +private fun BigLifeRow(value: Int, onChange: (Int) -> Unit) { + 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") { 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") { onChange(value + 1) } + } + } +} + +@Composable +private fun CounterIconButton(icon: ImageVector, contentDescription: String, onClick: () -> Unit) { + FilledTonalIconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { + Icon(icon, contentDescription = contentDescription) + } +} + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt new file mode 100644 index 0000000..29a2289 --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/screens/SetupScreen.kt @@ -0,0 +1,123 @@ +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: (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) } + + val names = remember { mutableStateListOf() } + 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") + } + + 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( + GameState( + players = players, + startingLife = startingLife, + trackPoison = trackPoison, + trackEnergy = trackEnergy, + trackExperience = trackExperience, + trackCommanderDamage = trackCommander + ) + ) + }) { + Text("Start game") + } + } +} + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt new file mode 100644 index 0000000..eee5fdb --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettings.kt @@ -0,0 +1,38 @@ +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.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +enum class ThemeMode { System, Light, Dark } + +private val Context.dataStore by preferencesDataStore(name = "app_settings") + +object AppSettingsRepository { + private val THEME_MODE = intPreferencesKey("theme_mode") + + fun themeMode(context: Context): Flow = + 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 + } + } + } +} + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt new file mode 100644 index 0000000..f4a3979 --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/settings/AppSettingsViewModel.kt @@ -0,0 +1,26 @@ +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 = + AppSettingsRepository.themeMode(application).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ThemeMode.System + ) + + fun setTheme(mode: ThemeMode) { + viewModelScope.launch { + AppSettingsRepository.setThemeMode(getApplication(), mode) + } + } +} + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt b/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt new file mode 100644 index 0000000..227fa07 --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/state/GameState.kt @@ -0,0 +1,34 @@ +package com.atridad.magiccounter.ui.state + +import androidx.compose.runtime.Immutable + +@Immutable +data class CommanderDamage( + val fromPlayerId: Int, + val damage: Int +) + +@Immutable +data class PlayerState( + val id: Int, + val name: String, + val life: Int, + val poison: Int, + val energy: Int, + val experience: Int, + val commanderDamages: Map +) + +@Immutable +data class GameState( + val players: List, + val startingLife: Int, + val trackPoison: Boolean, + val trackEnergy: Boolean, + val trackExperience: Boolean, + val trackCommanderDamage: Boolean +) + +fun defaultPlayerName(index: Int): String = "Player ${index + 1}" + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/theme/Theme.kt b/app/src/main/java/com/atridad/magiccounter/ui/theme/Theme.kt new file mode 100644 index 0000000..51cff41 --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/theme/Theme.kt @@ -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 + ) +} + + diff --git a/app/src/main/java/com/atridad/magiccounter/ui/theme/Type.kt b/app/src/main/java/com/atridad/magiccounter/ui/theme/Type.kt new file mode 100644 index 0000000..fe2b1cb --- /dev/null +++ b/app/src/main/java/com/atridad/magiccounter/ui/theme/Type.kt @@ -0,0 +1,7 @@ +package com.atridad.magiccounter.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_camera.xml b/app/src/main/res/drawable/ic_menu_camera.xml new file mode 100644 index 0000000..634fe92 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_camera.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_menu_gallery.xml b/app/src/main/res/drawable/ic_menu_gallery.xml new file mode 100644 index 0000000..03c7709 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_slideshow.xml b/app/src/main/res/drawable/ic_menu_slideshow.xml new file mode 100644 index 0000000..5e9e163 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_slideshow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/side_nav_bar.xml b/app/src/main/res/drawable/side_nav_bar.xml new file mode 100644 index 0000000..6d81870 --- /dev/null +++ b/app/src/main/res/drawable/side_nav_bar.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml new file mode 100644 index 0000000..22d7f00 --- /dev/null +++ b/app/src/main/res/values-land/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..97a417c --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,3 @@ + +