v3.0.0 - Preset Phases and Better Dark Mode
This commit is contained in:
@@ -4,26 +4,46 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.crsmthw.phase10tracker.data.ThemePreferenceManager
|
||||
import com.crsmthw.phase10tracker.ui.Phase10NavHost
|
||||
import com.crsmthw.phase10tracker.ui.ThemeViewModel
|
||||
import com.crsmthw.phase10tracker.ui.ThemeViewModelFactory
|
||||
import com.crsmthw.phase10tracker.ui.theme.Phase10Theme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val themeVm: ThemeViewModel by viewModels {
|
||||
ThemeViewModelFactory(ThemePreferenceManager(applicationContext))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
Phase10Theme {
|
||||
val themeMode by themeVm.themeMode.collectAsState()
|
||||
val amoledBlack by themeVm.amoledBlack.collectAsState()
|
||||
|
||||
Phase10Theme(
|
||||
themeMode = themeMode,
|
||||
amoledBlack = amoledBlack,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
Phase10NavHost(navController = navController)
|
||||
Phase10NavHost(
|
||||
navController = navController,
|
||||
themeVm = themeVm
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.crsmthw.phase10tracker.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
// ── Theme Mode ─────────────────────────────────────────────────────────────────
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
// One DataStore instance per process — scoped to the application context
|
||||
private val Context.themeDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "theme_prefs"
|
||||
)
|
||||
|
||||
// ── Preference Manager ─────────────────────────────────────────────────────────
|
||||
class ThemePreferenceManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val KEY_THEME_MODE = intPreferencesKey("theme_mode")
|
||||
private val KEY_AMOLED = booleanPreferencesKey("amoled_black")
|
||||
}
|
||||
|
||||
/** Emits the saved ThemeMode; defaults to SYSTEM on first run. */
|
||||
val themeMode: Flow<ThemeMode> = context.themeDataStore.data.map { prefs ->
|
||||
when (prefs[KEY_THEME_MODE] ?: 0) {
|
||||
1 -> ThemeMode.LIGHT
|
||||
2 -> ThemeMode.DARK
|
||||
else -> ThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits whether AMOLED Pure Black mode is enabled; defaults to false. */
|
||||
val amoledBlack: Flow<Boolean> = context.themeDataStore.data.map { prefs ->
|
||||
prefs[KEY_AMOLED] ?: false
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
context.themeDataStore.edit { prefs ->
|
||||
prefs[KEY_THEME_MODE] = when (mode) {
|
||||
ThemeMode.LIGHT -> 1
|
||||
ThemeMode.DARK -> 2
|
||||
ThemeMode.SYSTEM -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAmoledBlack(enabled: Boolean) {
|
||||
context.themeDataStore.edit { prefs ->
|
||||
prefs[KEY_AMOLED] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,18 +89,22 @@ interface GamePlayerDao {
|
||||
suspend fun getGamePlayerById(id: Long): GamePlayerEntity?
|
||||
}
|
||||
|
||||
// ── CustomRuleSet DAO ─────────────────────────────────────────────────────────
|
||||
// ── CustomPhaseSet DAO ───────────────────────────────────────────────────────
|
||||
|
||||
@Dao
|
||||
interface CustomRuleSetDao {
|
||||
@Query("SELECT * FROM custom_rule_sets ORDER BY createdAt DESC")
|
||||
fun getAllRuleSets(): Flow<List<CustomRuleSetEntity>>
|
||||
interface CustomPhaseSetDao {
|
||||
|
||||
@Query("SELECT * FROM custom_phase_sets ORDER BY createdAt DESC")
|
||||
fun getAllPhaseSets(): Flow<List<CustomPhaseSetEntity>>
|
||||
|
||||
@Query("SELECT * FROM custom_phase_sets WHERE id = :id")
|
||||
suspend fun getPhaseSetById(id: Long): CustomPhaseSetEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertRuleSet(ruleSet: CustomRuleSetEntity): Long
|
||||
suspend fun insertPhaseSet(phaseSet: CustomPhaseSetEntity): Long
|
||||
|
||||
@Delete
|
||||
suspend fun deleteRuleSet(ruleSet: CustomRuleSetEntity)
|
||||
suspend fun deletePhaseSet(phaseSet: CustomPhaseSetEntity)
|
||||
}
|
||||
|
||||
@Dao
|
||||
|
||||
@@ -4,17 +4,32 @@ import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.crsmthw.phase10tracker.data.model.*
|
||||
|
||||
// ── Migration 2 → 3 ───────────────────────────────────────────────────────────
|
||||
// • Adds phaseSetId column to games table
|
||||
// • Renames custom_rule_sets table to custom_phase_sets
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Store which phase set was used when the game was created
|
||||
db.execSQL("ALTER TABLE games ADD COLUMN phaseSetId INTEGER NOT NULL DEFAULT -1")
|
||||
// Rename table to match the updated entity name
|
||||
db.execSQL("ALTER TABLE custom_rule_sets RENAME TO custom_phase_sets")
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
PlayerEntity::class,
|
||||
GameEntity::class,
|
||||
GamePlayerEntity::class,
|
||||
RoundEntity::class,
|
||||
CustomRuleSetEntity::class
|
||||
CustomPhaseSetEntity::class
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class Phase10Database : RoomDatabase() {
|
||||
@@ -23,7 +38,7 @@ abstract class Phase10Database : RoomDatabase() {
|
||||
abstract fun gameDao(): GameDao
|
||||
abstract fun gamePlayerDao(): GamePlayerDao
|
||||
abstract fun roundDao(): RoundDao
|
||||
abstract fun customRuleSetDao(): CustomRuleSetDao
|
||||
abstract fun customPhaseSetDao(): CustomPhaseSetDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
@@ -36,6 +51,7 @@ abstract class Phase10Database : RoomDatabase() {
|
||||
Phase10Database::class.java,
|
||||
"phase10_tracker.db"
|
||||
)
|
||||
.addMigrations(MIGRATION_2_3)
|
||||
.fallbackToDestructiveMigration(true)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.crsmthw.phase10tracker.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "custom_rulesets")
|
||||
data class CustomRulesetEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
// Stored as pipe-separated "title|description" per phase, comma-separated phases
|
||||
// e.g. "2 Sets of 3|Two groups...,1 Set of 3 + 1 Run of 4|..."
|
||||
val rulesData: String,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Helper to serialize/deserialize rules list to/from the stored string
|
||||
fun List<PhaseRule>.toRulesData(): String =
|
||||
joinToString(",") { "${it.title}|${it.description}" }
|
||||
|
||||
fun String.toPhaseRules(): List<PhaseRule> =
|
||||
split(",").mapIndexed { index, entry ->
|
||||
val parts = entry.split("|")
|
||||
PhaseRule(
|
||||
phaseNumber = index + 1,
|
||||
title = parts.getOrElse(0) { "Phase ${index + 1}" },
|
||||
description = parts.getOrElse(1) { "" }
|
||||
)
|
||||
}
|
||||
|
||||
// UI model for ruleset picker
|
||||
data class RulesetOption(
|
||||
val id: Long, // -1 = official
|
||||
val name: String,
|
||||
val rules: List<PhaseRule>
|
||||
) {
|
||||
companion object {
|
||||
val OFFICIAL = RulesetOption(-1L, "Official Rules", OFFICIAL_PHASE_RULES)
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,11 @@ data class GameEntity(
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val finishedAt: Long? = null,
|
||||
val isComplete: Boolean = false,
|
||||
val winnerId: Long? = null, // references PlayerEntity.id
|
||||
val winnerId: Long? = null, // references PlayerEntity.id
|
||||
val currentRound: Int = 1,
|
||||
val currentDealerIndex: Int = 0 // index into the ordered player list
|
||||
val currentDealerIndex: Int = 0, // index into the ordered player list
|
||||
// -1 = Official Phases, -2..-15 = preset index, positive = custom phase set DB id
|
||||
val phaseSetId: Long = -1L
|
||||
)
|
||||
|
||||
// ── Per-player state within a game ──────────────────────────────────────────
|
||||
@@ -60,10 +62,10 @@ data class GamePlayerEntity(
|
||||
val isEliminated: Boolean = false // completed Phase 10 — still tracked
|
||||
)
|
||||
|
||||
// ── Custom rule sets ─────────────────────────────────────────────────────────
|
||||
// ── Custom phase sets ────────────────────────────────────────────────────────
|
||||
|
||||
@Entity(tableName = "custom_rule_sets")
|
||||
data class CustomRuleSetEntity(
|
||||
@Entity(tableName = "custom_phase_sets")
|
||||
data class CustomPhaseSetEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val rulesJson: String, // JSON array of PhaseRule serialized
|
||||
|
||||
@@ -6,6 +6,202 @@ data class PhaseRule(
|
||||
val description: String
|
||||
)
|
||||
|
||||
// ── Preset phase set container ─────────────────────────────────────────────────
|
||||
|
||||
data class PresetPhaseSet(
|
||||
val name: String,
|
||||
val phases: List<PhaseRule>
|
||||
)
|
||||
|
||||
/** Converts a list of title strings into numbered PhaseRules (description = title). */
|
||||
private fun phaseList(vararg titles: String): List<PhaseRule> =
|
||||
titles.mapIndexed { i, t -> PhaseRule(i + 1, t, t) }
|
||||
|
||||
val PRESET_PHASE_SETS: List<PresetPhaseSet> = listOf(
|
||||
|
||||
PresetPhaseSet("Island Paradise", phaseList(
|
||||
"1 run of 7",
|
||||
"1 set of 2 + 2 sets of 3",
|
||||
"1 run of 6 + 1 set of 2",
|
||||
"3 sets of 2 + 1 set of 3",
|
||||
"1 set of 3 + 1 run of 6",
|
||||
"2 runs of 4",
|
||||
"3 cards of one color + 1 set of 4",
|
||||
"8 cards of one color",
|
||||
"4 cards of one color + 1 set of 5",
|
||||
"9 cards of one color"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Cocoa Canyon", phaseList(
|
||||
"6 cards of one color",
|
||||
"2 sets of 3",
|
||||
"1 run of 8",
|
||||
"1 set of 3 + 1 run of 5",
|
||||
"7 cards of even or odd",
|
||||
"1 run of 4",
|
||||
"1 set of 5 + 1 run of 4",
|
||||
"6 cards of even or odd",
|
||||
"1 set of 4 + 1 run of 3",
|
||||
"1 set of 5 + 1 run of 5"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Disco Fever", phaseList(
|
||||
"8 even or odd cards",
|
||||
"9 even or odd cards",
|
||||
"1 color run of 3 + 2 sets of 2",
|
||||
"7 of one color",
|
||||
"1 color run of 5 + 2 sets of 2",
|
||||
"Same color even or odd of 3 + same color even or odd of 4",
|
||||
"1 color run of 4 + 1 set of 4",
|
||||
"1 color run of 4 + 3 sets of 2",
|
||||
"1 run of 3 + 2 sets of 3",
|
||||
"1 run of 3 + 1 set of 4 + 1 set of 3"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Cupcake Lounge", phaseList(
|
||||
"3 of one color + 3 of one color + 4 of one color",
|
||||
"1 color run of 3 + 2 sets of 2",
|
||||
"1 set of 4 + 1 wild",
|
||||
"2 sets of 3",
|
||||
"1 run of 7",
|
||||
"1 set of 4",
|
||||
"2 color even or odd of 4",
|
||||
"1 run of 9",
|
||||
"1 color run of 5 + 2 sets of 2",
|
||||
"1 color run of 6 + 1 set of 2"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Mountain Vista", phaseList(
|
||||
"1 run of 3 + 3 sets of 2",
|
||||
"1 run of 8",
|
||||
"1 run of 9",
|
||||
"1 color run of 3 + 1 set of 3",
|
||||
"1 set of 2 + 2 sets of 3",
|
||||
"1 set of 2 + 1 set of 3 + 1 set of 4",
|
||||
"4 of one color + 6 of one color",
|
||||
"5 of one color + 5 of one color",
|
||||
"1 run of 5 + 1 set of 3 + 1 set of 2",
|
||||
"1 run of 3 + 1 set of 4 + 1 set of 3"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Prehistoric Valley", phaseList(
|
||||
"1 even or odd of 9",
|
||||
"1 even or odd of 10",
|
||||
"1 run of 8",
|
||||
"1 run of 10",
|
||||
"2 sets of 3",
|
||||
"2 sets of 4",
|
||||
"1 color run of 4",
|
||||
"1 color run of 3 + 3 of one color",
|
||||
"1 set of 3 + 1 run of 4",
|
||||
"1 set of 4 + 1 run of 6"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Moonlight Drive-In", phaseList(
|
||||
"1 set of 4 + 2 sets of 2",
|
||||
"2 sets of 3 + 3 of one color",
|
||||
"1 run of 7",
|
||||
"1 run of 8",
|
||||
"1 set of 2 + 2 sets of 3",
|
||||
"1 set of 5",
|
||||
"1 run of 9",
|
||||
"1 run of 6 + 2 sets of 2",
|
||||
"1 run of 8 + 1 set of 2",
|
||||
"1 set of 4 + 1 run of 6"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Ancient Greece", phaseList(
|
||||
"1 set of 2 + 1 run of 6",
|
||||
"1 even or odd of 9",
|
||||
"1 even or odd of 10",
|
||||
"1 color run of 3 + 1 set of 3",
|
||||
"1 set of 3 + 1 run of 5",
|
||||
"1 set of 5 + 1 run of 4",
|
||||
"1 color run of 5",
|
||||
"1 color even or odd of 3 + 1 color even or odd of 5",
|
||||
"5 sets of 2",
|
||||
"2 sets of 3 + 2 sets of 2"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Jazz Club", phaseList(
|
||||
"1 even or odd of 5",
|
||||
"1 color run of 3 + 1 set of 3",
|
||||
"1 set of 2 + 1 run of 4",
|
||||
"1 color run of 4",
|
||||
"1 run of 4",
|
||||
"1 set of 4 + 1 run of 3",
|
||||
"1 color even or odd of 5",
|
||||
"1 color run of 5 + 1 set of 2",
|
||||
"1 color even or odd of 6",
|
||||
"1 color run of 5 + 3 of one color"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Vintage Gas Station", phaseList(
|
||||
"1 set of 3 + 1 run of 5",
|
||||
"1 run of 4 + 1 set of 3 + 1 set of 2",
|
||||
"1 run of 3 + 1 set of 3 + 2 sets of 2",
|
||||
"1 color run of 4",
|
||||
"1 color run of 4 + 1 set of 2",
|
||||
"1 color run of 4 + 2 sets of 2",
|
||||
"1 set of 5 + 1 run of 4",
|
||||
"1 color even or odd of 5",
|
||||
"1 color even or odd of 6",
|
||||
"1 color run of 3 + 3 of one color + 1 set of 2"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Ocean Reef", phaseList(
|
||||
"1 run of 7",
|
||||
"1 set of 4 + 1 set of 3",
|
||||
"1 color run of 5 + 1 set of 2",
|
||||
"1 even or odd of 10",
|
||||
"2 sets of 5",
|
||||
"3 sets of 2",
|
||||
"1 color run of 3 + 1 set of 3",
|
||||
"1 color even or odd of 3 + 1 color even or odd of 4",
|
||||
"1 run of 7 + 1 set of 2",
|
||||
"1 run of 6"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Candy Castle", phaseList(
|
||||
"3 sets of 2",
|
||||
"1 run of 5 + 1 set of 2",
|
||||
"1 set of 3 + 1 run of 4",
|
||||
"Even or odd of 7",
|
||||
"1 run of 3 + 1 set of 2",
|
||||
"1 run of 7",
|
||||
"1 set of 3 + 1 run of 5",
|
||||
"1 run of 8",
|
||||
"2 sets of 4",
|
||||
"2 runs of 3"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("The Empire Strikes Back", phaseList(
|
||||
"3 sets of 3",
|
||||
"1 run of 7 + 1 set of 2",
|
||||
"4 even/odd of same color + 2 sets of 2",
|
||||
"1 set of 4 + 1 set of 3 + 1 set of 2",
|
||||
"2 sets of 5",
|
||||
"1 run of 9",
|
||||
"1 color run of 5 + 1 set of 3",
|
||||
"1 color run of 4 + 1 run of 4",
|
||||
"5 even/odd of same color + 1 set of 4",
|
||||
"3 sets of 3 + a skip card"
|
||||
)),
|
||||
|
||||
PresetPhaseSet("Sets Gone Wild", phaseList(
|
||||
"1 set of 4 + 1 run of 5",
|
||||
"2 sets of 4",
|
||||
"3 sets of 3 + a wild card",
|
||||
"1 set of 4 + 1 color run of 6",
|
||||
"2 sets of 5",
|
||||
"1 set of 5 + 1 color run of 5",
|
||||
"1 set of 6 + 1 color run of 4",
|
||||
"1 set of 4 + 1 color run of 3 + even or odd of 3",
|
||||
"2 sets of 4 + 1 set of 2",
|
||||
"1 color run of 10"
|
||||
)),
|
||||
)
|
||||
|
||||
val OFFICIAL_PHASE_RULES = listOf(
|
||||
PhaseRule(1, "2 sets of 3", "Two groups of 3 cards with the same number."),
|
||||
PhaseRule(2, "1 set of 3 + 1 run of 4", "One group of 3 same-number cards, plus 4 consecutive numbers."),
|
||||
|
||||
@@ -22,8 +22,8 @@ data class RoundEntry(
|
||||
val autoCompleted: Boolean = false // true when score was auto-inferred < threshold
|
||||
)
|
||||
|
||||
// Custom rule set UI model
|
||||
data class CustomRuleSet(
|
||||
// Custom phase set UI model
|
||||
data class CustomPhaseSet(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val phases: List<PhaseRule>
|
||||
|
||||
@@ -9,7 +9,7 @@ class GameRepository(
|
||||
private val gameDao: GameDao,
|
||||
private val gamePlayerDao: GamePlayerDao,
|
||||
private val roundDao: RoundDao,
|
||||
private val customRuleSetDao: CustomRuleSetDao
|
||||
private val customPhaseSetDao: CustomPhaseSetDao
|
||||
) {
|
||||
|
||||
// ── Players ──────────────────────────────────────────────────────────────
|
||||
@@ -30,10 +30,13 @@ class GameRepository(
|
||||
|
||||
// ── Start Game ───────────────────────────────────────────────────────────
|
||||
|
||||
suspend fun startNewGame(orderedPlayers: List<PlayerEntity>): Long {
|
||||
suspend fun startNewGame(
|
||||
orderedPlayers: List<PlayerEntity>,
|
||||
phaseSetId: Long = -1L
|
||||
): Long {
|
||||
// Wipe any lingering incomplete games before starting fresh
|
||||
gameDao.cancelAllIncompleteGames()
|
||||
val gameId = gameDao.insertGame(GameEntity())
|
||||
val gameId = gameDao.insertGame(GameEntity(phaseSetId = phaseSetId))
|
||||
val gamePlayers = orderedPlayers.mapIndexed { index, player ->
|
||||
GamePlayerEntity(
|
||||
gameId = gameId,
|
||||
@@ -48,6 +51,25 @@ class GameRepository(
|
||||
return gameId
|
||||
}
|
||||
|
||||
// ── Phase Rules Resolution ───────────────────────────────────────────────
|
||||
// Resolves the correct List<PhaseRule> from the ID stored on the game:
|
||||
// -1 → Official Phases
|
||||
// -2..-15 → Preset at index = abs(id) - 2
|
||||
// positive → User-created custom phase set from DB
|
||||
|
||||
suspend fun resolvePhaseRules(phaseSetId: Long): List<PhaseRule> = when {
|
||||
phaseSetId == -1L -> OFFICIAL_PHASE_RULES
|
||||
phaseSetId < -1L -> {
|
||||
val presetIndex = (-phaseSetId - 2).toInt()
|
||||
PRESET_PHASE_SETS.getOrNull(presetIndex)?.phases ?: OFFICIAL_PHASE_RULES
|
||||
}
|
||||
else -> {
|
||||
customPhaseSetDao.getPhaseSetById(phaseSetId)
|
||||
?.let { parseRulesJson(it.rulesJson) }
|
||||
?: OFFICIAL_PHASE_RULES
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live Game State ──────────────────────────────────────────────────────
|
||||
|
||||
fun getGameById(gameId: Long): Flow<GameEntity?> = gameDao.getGameById(gameId)
|
||||
@@ -84,7 +106,6 @@ class GameRepository(
|
||||
val game = gameDao.getActiveGame() ?: return
|
||||
val gamePlayers = gamePlayerDao.getGamePlayersList(gameId)
|
||||
|
||||
// Build round records + update player state
|
||||
val rounds = entries.mapNotNull { entry ->
|
||||
val gp = gamePlayers.find { it.id == entry.gamePlayerId } ?: return@mapNotNull null
|
||||
val score = entry.scoreInput.trim().toIntOrNull() ?: 0
|
||||
@@ -93,12 +114,8 @@ class GameRepository(
|
||||
else gp.currentPhase
|
||||
val newScore = gp.totalScore + score
|
||||
|
||||
// Update the game player
|
||||
gamePlayerDao.updateGamePlayer(
|
||||
gp.copy(
|
||||
currentPhase = newPhase,
|
||||
totalScore = newScore
|
||||
)
|
||||
gp.copy(currentPhase = newPhase, totalScore = newScore)
|
||||
)
|
||||
|
||||
RoundEntity(
|
||||
@@ -112,7 +129,6 @@ class GameRepository(
|
||||
}
|
||||
roundDao.insertRounds(rounds)
|
||||
|
||||
// Advance round & dealer
|
||||
val playerCount = gamePlayers.size
|
||||
val nextDealerIndex = (game.currentDealerIndex + 1) % playerCount
|
||||
gameDao.advanceRound(
|
||||
@@ -121,14 +137,11 @@ class GameRepository(
|
||||
dealerIndex = nextDealerIndex
|
||||
)
|
||||
|
||||
// Check win condition — someone advanced past phase 10
|
||||
val updatedPlayers = gamePlayerDao.getGamePlayersList(gameId)
|
||||
val finishers = updatedPlayers.filter { it.currentPhase > 10 }
|
||||
if (finishers.isNotEmpty()) {
|
||||
// Highest phase first (all should be > 10 here), then lowest score
|
||||
val winnerScore = finishers.minOf { it.totalScore }
|
||||
val winners = finishers.filter { it.totalScore == winnerScore }
|
||||
// Use first winner for DB (tied winners both get stats updated)
|
||||
endGame(gameId, winners.map { it.playerId })
|
||||
}
|
||||
}
|
||||
@@ -141,7 +154,6 @@ class GameRepository(
|
||||
val resolvedWinnerIds: List<Long> = if (winnerIds != null) {
|
||||
winnerIds
|
||||
} else {
|
||||
// Early end: highest phase wins, then lowest score, then tie
|
||||
val highestPhase = gamePlayers.maxOf { it.currentPhase }
|
||||
val topPlayers = gamePlayers.filter { it.currentPhase == highestPhase }
|
||||
val lowestScore = topPlayers.minOf { it.totalScore }
|
||||
@@ -150,10 +162,7 @@ class GameRepository(
|
||||
|
||||
if (resolvedWinnerIds.isEmpty()) return
|
||||
|
||||
// Store first winner in game record (UI will show all tied winners)
|
||||
gameDao.finishGame(gameId, resolvedWinnerIds.first())
|
||||
|
||||
// Update lifetime stats for all players and all winners
|
||||
val playerIds = gamePlayers.map { it.playerId }
|
||||
playerDao.incrementGamesPlayed(playerIds)
|
||||
resolvedWinnerIds.forEach { playerDao.incrementGamesWon(it) }
|
||||
@@ -163,12 +172,6 @@ class GameRepository(
|
||||
|
||||
suspend fun getGameResults(gameId: Long): List<GameResult> {
|
||||
val gamePlayers = gamePlayerDao.getGamePlayersList(gameId)
|
||||
|
||||
// Get winner IDs from the finished game record
|
||||
val finishedGame = gameDao.getGameById(gameId).firstOrNull()
|
||||
val primaryWinnerId = finishedGame?.winnerId
|
||||
|
||||
// Determine all tied winners: same highest phase + same lowest score
|
||||
val highestPhase = gamePlayers.maxOf { it.currentPhase }
|
||||
val topPlayers = gamePlayers.filter { it.currentPhase == highestPhase }
|
||||
val lowestScore = topPlayers.minOf { it.totalScore }
|
||||
@@ -177,7 +180,6 @@ class GameRepository(
|
||||
.map { it.playerId }
|
||||
.toSet()
|
||||
|
||||
// Sort: highest phase first, then lowest score (ascending)
|
||||
return gamePlayers
|
||||
.sortedWith(compareByDescending<GamePlayerEntity> { it.currentPhase }
|
||||
.thenBy { it.totalScore })
|
||||
@@ -210,16 +212,15 @@ class GameRepository(
|
||||
// ── Cancel game (no winner, no stats update) ─────────────────────────────
|
||||
|
||||
suspend fun cancelGame(gameId: Long) {
|
||||
// Mark as complete but with no winner — no stats updated
|
||||
gameDao.finishGame(gameId, winnerId = -1L)
|
||||
}
|
||||
|
||||
// ── Custom Rule Sets ──────────────────────────────────────────────────────
|
||||
// ── Custom Phase Sets ──────────────────────────────────────────────────────
|
||||
|
||||
fun getAllCustomRuleSets(): Flow<List<CustomRuleSet>> =
|
||||
customRuleSetDao.getAllRuleSets().map { entities ->
|
||||
fun getAllCustomPhaseSets(): Flow<List<CustomPhaseSet>> =
|
||||
customPhaseSetDao.getAllPhaseSets().map { entities ->
|
||||
entities.map { entity ->
|
||||
CustomRuleSet(
|
||||
CustomPhaseSet(
|
||||
id = entity.id,
|
||||
name = entity.name,
|
||||
phases = parseRulesJson(entity.rulesJson)
|
||||
@@ -227,16 +228,16 @@ class GameRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCustomRuleSet(name: String, phases: List<PhaseRule>): Long {
|
||||
suspend fun saveCustomPhaseSet(name: String, phases: List<PhaseRule>): Long {
|
||||
val json = buildRulesJson(phases)
|
||||
return customRuleSetDao.insertRuleSet(
|
||||
CustomRuleSetEntity(name = name.trim(), rulesJson = json)
|
||||
return customPhaseSetDao.insertPhaseSet(
|
||||
CustomPhaseSetEntity(name = name.trim(), rulesJson = json)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteCustomRuleSet(ruleSet: CustomRuleSet) {
|
||||
customRuleSetDao.deleteRuleSet(
|
||||
CustomRuleSetEntity(id = ruleSet.id, name = ruleSet.name, rulesJson = "")
|
||||
suspend fun deleteCustomPhaseSet(phaseSet: CustomPhaseSet) {
|
||||
customPhaseSetDao.deletePhaseSet(
|
||||
CustomPhaseSetEntity(id = phaseSet.id, name = phaseSet.name, rulesJson = "")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ object Routes {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Phase10NavHost(navController: NavHostController) {
|
||||
fun Phase10NavHost(
|
||||
navController: NavHostController,
|
||||
themeVm: ThemeViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val db = remember { Phase10Database.getInstance(context) }
|
||||
val repo = remember {
|
||||
@@ -37,7 +40,7 @@ fun Phase10NavHost(navController: NavHostController) {
|
||||
db.gameDao(),
|
||||
db.gamePlayerDao(),
|
||||
db.roundDao(),
|
||||
db.customRuleSetDao()
|
||||
db.customPhaseSetDao()
|
||||
)
|
||||
}
|
||||
val factory = remember { ViewModelFactory(repo) }
|
||||
@@ -46,21 +49,27 @@ fun Phase10NavHost(navController: NavHostController) {
|
||||
|
||||
composable(Routes.HOME) {
|
||||
val vm: HomeViewModel = viewModel(factory = factory)
|
||||
val themeMode by themeVm.themeMode.collectAsState()
|
||||
val amoledBlack by themeVm.amoledBlack.collectAsState()
|
||||
HomeScreen(
|
||||
vm = vm,
|
||||
onContinueGame = { gameId -> navController.navigate(Routes.activeGame(gameId)) },
|
||||
onStartNew = { navController.navigate(Routes.GAME_SETUP) },
|
||||
onLeaderboard = { navController.navigate(Routes.LEADERBOARD) },
|
||||
onManagePlayers = { navController.navigate(Routes.PLAYER_ROSTER) },
|
||||
onCustomRules = { navController.navigate(Routes.CUSTOM_RULES) },
|
||||
onAbout = { navController.navigate(Routes.ABOUT) }
|
||||
vm = vm,
|
||||
themeMode = themeMode,
|
||||
amoledBlack = amoledBlack,
|
||||
onThemeModeChange = themeVm::setThemeMode,
|
||||
onAmoledBlackChange = themeVm::setAmoledBlack,
|
||||
onContinueGame = { gameId -> navController.navigate(Routes.activeGame(gameId)) },
|
||||
onStartNew = { navController.navigate(Routes.GAME_SETUP) },
|
||||
onLeaderboard = { navController.navigate(Routes.LEADERBOARD) },
|
||||
onManagePlayers = { navController.navigate(Routes.PLAYER_ROSTER) },
|
||||
onCustomRules = { navController.navigate(Routes.CUSTOM_RULES) },
|
||||
onAbout = { navController.navigate(Routes.ABOUT) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.PLAYER_ROSTER) {
|
||||
val vm: PlayerRosterViewModel = viewModel(factory = factory)
|
||||
PlayerRosterScreen(
|
||||
vm = vm,
|
||||
vm = vm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
@@ -68,7 +77,7 @@ fun Phase10NavHost(navController: NavHostController) {
|
||||
composable(Routes.GAME_SETUP) {
|
||||
val vm: GameSetupViewModel = viewModel(factory = factory)
|
||||
GameSetupScreen(
|
||||
vm = vm,
|
||||
vm = vm,
|
||||
onGameStarted = { gameId ->
|
||||
navController.navigate(Routes.activeGame(gameId)) {
|
||||
popUpTo(Routes.HOME)
|
||||
@@ -79,18 +88,20 @@ fun Phase10NavHost(navController: NavHostController) {
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.ACTIVE_GAME,
|
||||
route = Routes.ACTIVE_GAME,
|
||||
arguments = listOf(navArgument("gameId") { type = NavType.LongType })
|
||||
) { backStackEntry ->
|
||||
val gameId = backStackEntry.arguments!!.getLong("gameId")
|
||||
val gameId = backStackEntry.arguments!!.getLong("gameId")
|
||||
val gameFactory = remember { ViewModelFactory(repo, gameId) }
|
||||
val vm: ActiveGameViewModel = viewModel(factory = gameFactory)
|
||||
ActiveGameScreen(
|
||||
vm = vm,
|
||||
onEnterRound = { navController.navigate(Routes.roundEntry(gameId)) },
|
||||
onGameEnd = { navController.navigate(Routes.gameResults(gameId)) {
|
||||
popUpTo(Routes.HOME)
|
||||
}},
|
||||
vm = vm,
|
||||
onEnterRound = { navController.navigate(Routes.roundEntry(gameId)) },
|
||||
onGameEnd = {
|
||||
navController.navigate(Routes.gameResults(gameId)) {
|
||||
popUpTo(Routes.HOME)
|
||||
}
|
||||
},
|
||||
onGameCancelled = {
|
||||
navController.navigate(Routes.HOME) {
|
||||
popUpTo(Routes.HOME) { inclusive = true }
|
||||
@@ -101,28 +112,28 @@ fun Phase10NavHost(navController: NavHostController) {
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.ROUND_ENTRY,
|
||||
route = Routes.ROUND_ENTRY,
|
||||
arguments = listOf(navArgument("gameId") { type = NavType.LongType })
|
||||
) { backStackEntry ->
|
||||
val gameId = backStackEntry.arguments!!.getLong("gameId")
|
||||
val gameId = backStackEntry.arguments!!.getLong("gameId")
|
||||
val gameFactory = remember { ViewModelFactory(repo, gameId) }
|
||||
val vm: RoundEntryViewModel = viewModel(factory = gameFactory)
|
||||
RoundEntryScreen(
|
||||
vm = vm,
|
||||
vm = vm,
|
||||
onRoundSubmitted = { navController.popBackStack() },
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.GAME_RESULTS,
|
||||
route = Routes.GAME_RESULTS,
|
||||
arguments = listOf(navArgument("gameId") { type = NavType.LongType })
|
||||
) { backStackEntry ->
|
||||
val gameId = backStackEntry.arguments!!.getLong("gameId")
|
||||
val gameId = backStackEntry.arguments!!.getLong("gameId")
|
||||
val gameFactory = remember { ViewModelFactory(repo, gameId) }
|
||||
val vm: GameResultsViewModel = viewModel(factory = gameFactory)
|
||||
GameResultsScreen(
|
||||
vm = vm,
|
||||
vm = vm,
|
||||
onHome = {
|
||||
navController.navigate(Routes.HOME) {
|
||||
popUpTo(Routes.HOME) { inclusive = true }
|
||||
@@ -134,15 +145,15 @@ fun Phase10NavHost(navController: NavHostController) {
|
||||
composable(Routes.LEADERBOARD) {
|
||||
val vm: LeaderboardViewModel = viewModel(factory = factory)
|
||||
LeaderboardScreen(
|
||||
vm = vm,
|
||||
vm = vm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.CUSTOM_RULES) {
|
||||
val vm: CustomRulesViewModel = viewModel(factory = factory)
|
||||
CustomRulesScreen(
|
||||
vm = vm,
|
||||
val vm: CustomPhasesViewModel = viewModel(factory = factory)
|
||||
CustomPhasesScreen(
|
||||
vm = vm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.crsmthw.phase10tracker.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.crsmthw.phase10tracker.data.ThemeMode
|
||||
import com.crsmthw.phase10tracker.data.ThemePreferenceManager
|
||||
import com.crsmthw.phase10tracker.data.model.*
|
||||
import com.crsmthw.phase10tracker.data.repository.GameRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -12,8 +14,6 @@ import kotlinx.coroutines.launch
|
||||
|
||||
class HomeViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
|
||||
// Observes the DB directly — updates instantly when game state changes
|
||||
// (new game started, game ended, navigating back from active game etc.)
|
||||
private val _activeGame: StateFlow<GameEntity?> = repo.observeActiveGame()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
@@ -50,17 +50,28 @@ class GameSetupViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
val allPlayers: StateFlow<List<PlayerEntity>> = repo.getAllPlayers()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val customRuleSets: StateFlow<List<CustomRuleSet>> = repo.getAllCustomRuleSets()
|
||||
val customPhaseSets: StateFlow<List<CustomPhaseSet>> = repo.getAllCustomPhaseSets()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
/** Built-in preset sets — negative IDs so they never clash with DB rows. */
|
||||
val presetPhaseSets: List<CustomPhaseSet> = PRESET_PHASE_SETS.mapIndexed { i, p ->
|
||||
CustomPhaseSet(id = -(i + 2).toLong(), name = p.name, phases = p.phases)
|
||||
}
|
||||
|
||||
private val _selectedPlayers = MutableStateFlow<List<PlayerEntity>>(emptyList())
|
||||
val selectedPlayers: StateFlow<List<PlayerEntity>> = _selectedPlayers
|
||||
|
||||
private val _selectedRuleSet = MutableStateFlow<CustomRuleSet?>(null)
|
||||
val selectedRuleSet: StateFlow<CustomRuleSet?> = _selectedRuleSet
|
||||
private val _selectedPhaseSet = MutableStateFlow<CustomPhaseSet?>(null)
|
||||
val selectedPhaseSet: StateFlow<CustomPhaseSet?> = _selectedPhaseSet
|
||||
|
||||
fun selectRuleSet(ruleSet: CustomRuleSet?) {
|
||||
_selectedRuleSet.value = ruleSet
|
||||
fun selectPhaseSet(phaseSet: CustomPhaseSet?) {
|
||||
_selectedPhaseSet.value = phaseSet
|
||||
}
|
||||
|
||||
/** Picks a random phase set from official + all 14 presets + any user-created sets. */
|
||||
fun selectRandomPhaseSet() {
|
||||
val all: List<CustomPhaseSet?> = listOf(null) + presetPhaseSets + customPhaseSets.value
|
||||
_selectedPhaseSet.value = all.random()
|
||||
}
|
||||
|
||||
fun togglePlayer(player: PlayerEntity) {
|
||||
@@ -82,7 +93,6 @@ class GameSetupViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
fun addAndSelectNewPlayer(name: String) {
|
||||
viewModelScope.launch {
|
||||
val id = repo.addPlayer(name)
|
||||
// The allPlayers flow will update; we'll add it to selected via a new query
|
||||
val updatedPlayers = repo.getAllPlayers().first()
|
||||
val newPlayer = updatedPlayers.find { it.id == id } ?: return@launch
|
||||
_selectedPlayers.value = _selectedPlayers.value + newPlayer
|
||||
@@ -95,8 +105,9 @@ class GameSetupViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
fun startGame() {
|
||||
val players = _selectedPlayers.value
|
||||
if (players.size < 2) return
|
||||
val phaseSetId = _selectedPhaseSet.value?.id ?: -1L
|
||||
viewModelScope.launch {
|
||||
val id = repo.startNewGame(players)
|
||||
val id = repo.startNewGame(players, phaseSetId)
|
||||
_newGameId.value = id
|
||||
}
|
||||
}
|
||||
@@ -117,6 +128,11 @@ class ActiveGameViewModel(
|
||||
repo.getGameById(gameId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
/** Resolves the correct phase rules for this game from its stored phaseSetId. */
|
||||
val phaseRules: StateFlow<List<PhaseRule>> = gameState
|
||||
.map { game -> repo.resolvePhaseRules(game?.phaseSetId ?: -1L) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OFFICIAL_PHASE_RULES)
|
||||
|
||||
private val _gameFinished = MutableStateFlow(false)
|
||||
val gameFinished: StateFlow<Boolean> = _gameFinished
|
||||
|
||||
@@ -126,7 +142,6 @@ class ActiveGameViewModel(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
gameState.collect { game ->
|
||||
// Only trigger gameFinished if we haven't already cancelled
|
||||
if (game?.isComplete == true && !_gameCancelled.value) {
|
||||
_gameFinished.value = true
|
||||
}
|
||||
@@ -139,8 +154,6 @@ class ActiveGameViewModel(
|
||||
val players = boardState.value
|
||||
val anyScoreAboveZero = players.any { it.totalScore > 0 }
|
||||
if (!anyScoreAboveZero) {
|
||||
// Set cancelled flag FIRST before DB write, so the gameState
|
||||
// collector above won't fire gameFinished when isComplete flips
|
||||
_gameCancelled.value = true
|
||||
repo.cancelGame(gameId)
|
||||
} else {
|
||||
@@ -165,6 +178,11 @@ class RoundEntryViewModel(
|
||||
repo.getGameById(gameId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
/** Resolves the correct phase rules for this game from its stored phaseSetId. */
|
||||
val phaseRules: StateFlow<List<PhaseRule>> = gameState
|
||||
.map { game -> repo.resolvePhaseRules(game?.phaseSetId ?: -1L) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OFFICIAL_PHASE_RULES)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.getGamePlayers(gameId).collect { gamePlayers ->
|
||||
@@ -185,13 +203,6 @@ class RoundEntryViewModel(
|
||||
_entries.value = _entries.value.map { entry ->
|
||||
if (entry.gamePlayerId != gamePlayerId) return@map entry
|
||||
val scoreInt = score.trim().toIntOrNull()
|
||||
// Auto-complete only when score is non-empty AND definitively < 50.
|
||||
// If the user typed "6", that could become "60", "65" etc — don't auto-complete yet.
|
||||
// We consider a score "definitive" only if it's 0, OR if adding another digit
|
||||
// cannot bring it under 50 (i.e. it already has 2+ digits and is < 50,
|
||||
// or it is exactly 0).
|
||||
// Simplest safe rule: auto-complete only if score has >= 2 digits and < 50,
|
||||
// OR score is exactly 0 (went out).
|
||||
val autoComplete = scoreInt != null && (
|
||||
scoreInt == 0 || (score.trim().length >= 2 && scoreInt < PHASE_COMPLETE_THRESHOLD)
|
||||
)
|
||||
@@ -206,7 +217,6 @@ class RoundEntryViewModel(
|
||||
fun togglePhaseCompleted(gamePlayerId: Long) {
|
||||
_entries.value = _entries.value.map { entry ->
|
||||
if (entry.gamePlayerId != gamePlayerId) return@map entry
|
||||
// Only allow manual toggle if not auto-completed
|
||||
if (entry.autoCompleted) return@map entry
|
||||
entry.copy(phaseCompleted = !entry.phaseCompleted)
|
||||
}
|
||||
@@ -252,20 +262,51 @@ class LeaderboardViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
}
|
||||
|
||||
// ── Custom Rules ViewModel ────────────────────────────────────────────────────
|
||||
// ── Custom Phases ViewModel ───────────────────────────────────────────────────
|
||||
|
||||
class CustomRulesViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
class CustomPhasesViewModel(private val repo: GameRepository) : ViewModel() {
|
||||
|
||||
val customRuleSets: StateFlow<List<CustomRuleSet>> = repo.getAllCustomRuleSets()
|
||||
val customPhaseSets: StateFlow<List<CustomPhaseSet>> = repo.getAllCustomPhaseSets()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun saveRuleSet(name: String, phases: List<PhaseRule>) {
|
||||
fun savePhaseSet(name: String, phases: List<PhaseRule>) {
|
||||
if (name.isBlank() || phases.isEmpty()) return
|
||||
viewModelScope.launch { repo.saveCustomRuleSet(name, phases) }
|
||||
viewModelScope.launch { repo.saveCustomPhaseSet(name, phases) }
|
||||
}
|
||||
|
||||
fun deleteRuleSet(ruleSet: CustomRuleSet) {
|
||||
viewModelScope.launch { repo.deleteCustomRuleSet(ruleSet) }
|
||||
fun deletePhaseSet(phaseSet: CustomPhaseSet) {
|
||||
viewModelScope.launch { repo.deleteCustomPhaseSet(phaseSet) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme ViewModel ───────────────────────────────────────────────────────────
|
||||
|
||||
class ThemeViewModel(private val themePrefs: ThemePreferenceManager) : ViewModel() {
|
||||
|
||||
val themeMode: StateFlow<ThemeMode> = themePrefs.themeMode
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM)
|
||||
|
||||
val amoledBlack: StateFlow<Boolean> = themePrefs.amoledBlack
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { themePrefs.setThemeMode(mode) }
|
||||
}
|
||||
|
||||
fun setAmoledBlack(enabled: Boolean) {
|
||||
viewModelScope.launch { themePrefs.setAmoledBlack(enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeViewModelFactory(
|
||||
private val themePrefs: ThemePreferenceManager
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ThemeViewModel::class.java)) {
|
||||
return ThemeViewModel(themePrefs) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,14 +319,14 @@ class ViewModelFactory(
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = when {
|
||||
modelClass.isAssignableFrom(HomeViewModel::class.java) -> HomeViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(PlayerRosterViewModel::class.java) -> PlayerRosterViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(GameSetupViewModel::class.java) -> GameSetupViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(ActiveGameViewModel::class.java) -> ActiveGameViewModel(repo, gameId) as T
|
||||
modelClass.isAssignableFrom(RoundEntryViewModel::class.java) -> RoundEntryViewModel(repo, gameId) as T
|
||||
modelClass.isAssignableFrom(GameResultsViewModel::class.java) -> GameResultsViewModel(repo, gameId) as T
|
||||
modelClass.isAssignableFrom(LeaderboardViewModel::class.java) -> LeaderboardViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(CustomRulesViewModel::class.java) -> CustomRulesViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(HomeViewModel::class.java) -> HomeViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(PlayerRosterViewModel::class.java) -> PlayerRosterViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(GameSetupViewModel::class.java) -> GameSetupViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(ActiveGameViewModel::class.java) -> ActiveGameViewModel(repo, gameId) as T
|
||||
modelClass.isAssignableFrom(RoundEntryViewModel::class.java) -> RoundEntryViewModel(repo, gameId) as T
|
||||
modelClass.isAssignableFrom(GameResultsViewModel::class.java) -> GameResultsViewModel(repo, gameId) as T
|
||||
modelClass.isAssignableFrom(LeaderboardViewModel::class.java) -> LeaderboardViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(CustomPhasesViewModel::class.java) -> CustomPhasesViewModel(repo) as T
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,17 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
|
||||
import com.crsmthw.phase10tracker.data.model.PhaseRule
|
||||
import com.crsmthw.phase10tracker.data.model.PlayerGameState
|
||||
import com.crsmthw.phase10tracker.data.model.getPhaseRule
|
||||
import com.crsmthw.phase10tracker.ui.ActiveGameViewModel
|
||||
|
||||
private enum class ViewMode { SCORES, PHASES }
|
||||
|
||||
// ── Helper: resolve the right PhaseRule from the game's list ─────────────────
|
||||
|
||||
private fun List<PhaseRule>.forPhase(phase: Int): PhaseRule =
|
||||
getOrElse(phase - 1) { last() }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ActiveGameScreen(
|
||||
@@ -36,23 +41,18 @@ fun ActiveGameScreen(
|
||||
val gameState by vm.gameState.collectAsState()
|
||||
val gameFinished by vm.gameFinished.collectAsState()
|
||||
val gameCancelled by vm.gameCancelled.collectAsState()
|
||||
val phaseRules by vm.phaseRules.collectAsState()
|
||||
|
||||
var showEndGameDialog by remember { mutableStateOf(false) }
|
||||
// Single-pane tab mode (compact screens only)
|
||||
var viewMode by remember { mutableStateOf(ViewMode.SCORES) }
|
||||
|
||||
LaunchedEffect(gameFinished) { if (gameFinished) onGameEnd() }
|
||||
LaunchedEffect(gameCancelled) { if (gameCancelled) onGameCancelled() }
|
||||
|
||||
// Detect window width — expanded = unfolded foldable or tablet
|
||||
// Medium and above (≥600dp) = show both panes side by side
|
||||
// This covers unfolded Z Fold 6 and tablets
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isTwoPane = adaptiveInfo.windowSizeClass
|
||||
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
|
||||
|
||||
// SupportingPaneScaffold navigator — handles showing 1 or 2 panes automatically
|
||||
|
||||
if (showEndGameDialog) {
|
||||
val allZero = boardState.all { it.totalScore == 0 }
|
||||
AlertDialog(
|
||||
@@ -117,29 +117,25 @@ fun ActiveGameScreen(
|
||||
}
|
||||
) { padding ->
|
||||
if (isTwoPane) {
|
||||
// ── Wide layout: both panes side by side ─────────────────────────
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Left pane — Scores
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
PaneSectionHeader("Scores")
|
||||
ScoresView(boardState = boardState, fabClearance = false)
|
||||
ScoresView(boardState = boardState, phaseRules = phaseRules, fabClearance = false)
|
||||
}
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
)
|
||||
// Right pane — By Phase
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
PaneSectionHeader("By Phase")
|
||||
PhasesView(boardState = boardState, fabClearance = false)
|
||||
PhasesView(boardState = boardState, phaseRules = phaseRules, fabClearance = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Compact layout: single pane with tab switcher ─────────────────
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -165,8 +161,8 @@ fun ActiveGameScreen(
|
||||
label = "viewMode"
|
||||
) { mode ->
|
||||
when (mode) {
|
||||
ViewMode.SCORES -> ScoresView(boardState, fabClearance = true)
|
||||
ViewMode.PHASES -> PhasesView(boardState, fabClearance = true)
|
||||
ViewMode.SCORES -> ScoresView(boardState, phaseRules, fabClearance = true)
|
||||
ViewMode.PHASES -> PhasesView(boardState, phaseRules, fabClearance = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +193,7 @@ private fun PaneSectionHeader(title: String) {
|
||||
@Composable
|
||||
private fun ScoresView(
|
||||
boardState: List<PlayerGameState>,
|
||||
phaseRules: List<PhaseRule>,
|
||||
fabClearance: Boolean
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -205,7 +202,7 @@ private fun ScoresView(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
itemsIndexed(boardState, key = { _, p -> p.gamePlayerId }) { index, player ->
|
||||
PlayerScoreCard(player = player, rank = index + 1)
|
||||
PlayerScoreCard(player = player, rank = index + 1, phaseRules = phaseRules)
|
||||
}
|
||||
if (fabClearance) item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
@@ -216,6 +213,7 @@ private fun ScoresView(
|
||||
@Composable
|
||||
private fun PhasesView(
|
||||
boardState: List<PlayerGameState>,
|
||||
phaseRules: List<PhaseRule>,
|
||||
fabClearance: Boolean
|
||||
) {
|
||||
val grouped = boardState
|
||||
@@ -228,7 +226,7 @@ private fun PhasesView(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
grouped.forEach { (phase, players) ->
|
||||
val rule = getPhaseRule(phase)
|
||||
val rule = phaseRules.forPhase(phase)
|
||||
item(key = "header_$phase") {
|
||||
PhaseGroupHeader(phase = phase, rule = rule.title)
|
||||
}
|
||||
@@ -331,24 +329,28 @@ private fun PhaseGroupPlayerCard(player: PlayerGameState) {
|
||||
// ── Score card ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PlayerScoreCard(player: PlayerGameState, rank: Int) {
|
||||
private fun PlayerScoreCard(
|
||||
player: PlayerGameState,
|
||||
rank: Int,
|
||||
phaseRules: List<PhaseRule>
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val phaseRule = remember(player.currentPhase) { getPhaseRule(player.currentPhase) }
|
||||
val phase = minOf(player.currentPhase, 10)
|
||||
val phaseRule = phaseRules.forPhase(phase)
|
||||
|
||||
val cardColor = when (rank) {
|
||||
1 -> MaterialTheme.colorScheme.primaryContainer
|
||||
2 -> MaterialTheme.colorScheme.secondaryContainer
|
||||
1 -> MaterialTheme.colorScheme.primaryContainer
|
||||
2 -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val badgeColor = when (rank) {
|
||||
1 -> MaterialTheme.colorScheme.primary
|
||||
2 -> MaterialTheme.colorScheme.secondary
|
||||
1 -> MaterialTheme.colorScheme.primary
|
||||
2 -> MaterialTheme.colorScheme.secondary
|
||||
else -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
}
|
||||
val badgeTextColor = when (rank) {
|
||||
1 -> MaterialTheme.colorScheme.onPrimary
|
||||
2 -> MaterialTheme.colorScheme.onSecondary
|
||||
1 -> MaterialTheme.colorScheme.onPrimary
|
||||
2 -> MaterialTheme.colorScheme.onSecondary
|
||||
else -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
|
||||
|
||||
+101
-19
@@ -15,30 +15,32 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.crsmthw.phase10tracker.data.model.CustomRuleSet
|
||||
import com.crsmthw.phase10tracker.data.model.CustomPhaseSet
|
||||
import com.crsmthw.phase10tracker.data.model.OFFICIAL_PHASE_RULES
|
||||
import com.crsmthw.phase10tracker.data.model.PhaseRule
|
||||
import com.crsmthw.phase10tracker.ui.CustomRulesViewModel
|
||||
import com.crsmthw.phase10tracker.data.model.PRESET_PHASE_SETS
|
||||
import com.crsmthw.phase10tracker.data.model.PresetPhaseSet
|
||||
import com.crsmthw.phase10tracker.ui.CustomPhasesViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CustomRulesScreen(
|
||||
vm: CustomRulesViewModel,
|
||||
fun CustomPhasesScreen(
|
||||
vm: CustomPhasesViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val ruleSets by vm.customRuleSets.collectAsState()
|
||||
val ruleSets by vm.customPhaseSets.collectAsState()
|
||||
var showCreateSheet by remember { mutableStateOf(false) }
|
||||
var ruleSetToDelete by remember { mutableStateOf<CustomRuleSet?>(null) }
|
||||
var ruleSetToDelete by remember { mutableStateOf<CustomPhaseSet?>(null) }
|
||||
|
||||
ruleSetToDelete?.let { rs ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { ruleSetToDelete = null },
|
||||
icon = { Icon(Icons.Filled.DeleteOutline, null) },
|
||||
title = { Text("Delete \"${rs.name}\"?") },
|
||||
text = { Text("This rule set will be permanently deleted.") },
|
||||
text = { Text("This phase set will be permanently deleted.") },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { vm.deleteRuleSet(rs); ruleSetToDelete = null },
|
||||
onClick = { vm.deletePhaseSet(rs); ruleSetToDelete = null },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Delete") }
|
||||
},
|
||||
@@ -51,7 +53,7 @@ fun CustomRulesScreen(
|
||||
if (showCreateSheet) {
|
||||
CreateRuleSetSheet(
|
||||
onSave = { name, phases ->
|
||||
vm.saveRuleSet(name, phases)
|
||||
vm.savePhaseSet(name, phases)
|
||||
showCreateSheet = false
|
||||
},
|
||||
onDismiss = { showCreateSheet = false }
|
||||
@@ -61,7 +63,7 @@ fun CustomRulesScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Custom Rules") },
|
||||
title = { Text("Custom Phases") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
@@ -73,7 +75,7 @@ fun CustomRulesScreen(
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showCreateSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, null) },
|
||||
text = { Text("New Rule Set") }
|
||||
text = { Text("New Phase Set") }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
@@ -87,7 +89,7 @@ fun CustomRulesScreen(
|
||||
// Official rules reference card
|
||||
item {
|
||||
Text(
|
||||
"Official Rules (reference)",
|
||||
"Official Phases (reference)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
@@ -129,11 +131,26 @@ fun CustomRulesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preset Phase Sets ────────────────────────────────────────────
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Preset Phase Sets",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(PRESET_PHASE_SETS, key = { "preset_${it.name}" }) { preset ->
|
||||
PresetPhaseSetCard(preset = preset)
|
||||
}
|
||||
|
||||
// ── User Custom Phase Sets ───────────────────────────────────────
|
||||
if (ruleSets.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Your Custom Rule Sets",
|
||||
"Your Custom Phase Sets",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
@@ -153,7 +170,7 @@ fun CustomRulesScreen(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"No custom rule sets yet.\nTap + to create one.",
|
||||
"No custom phase sets yet.\nTap + to create one.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -168,7 +185,7 @@ fun CustomRulesScreen(
|
||||
|
||||
@Composable
|
||||
private fun CustomRuleSetCard(
|
||||
ruleSet: CustomRuleSet,
|
||||
ruleSet: CustomPhaseSet,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -238,6 +255,71 @@ private fun CustomRuleSetCard(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preset Phase Set card (read-only, no delete) ──────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PresetPhaseSetCard(preset: PresetPhaseSet) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
preset.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
Text(
|
||||
"${preset.phases.size} phases",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(
|
||||
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
preset.phases.forEachIndexed { index, phase ->
|
||||
if (index > 0) Spacer(Modifier.height(4.dp))
|
||||
Row {
|
||||
Text(
|
||||
"P${phase.phaseNumber}:",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.width(32.dp)
|
||||
)
|
||||
Text(
|
||||
phase.title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create Rule Set bottom sheet ──────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -267,7 +349,7 @@ private fun CreateRuleSetSheet(
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Text(
|
||||
"New Rule Set",
|
||||
"New Phase Set",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -276,7 +358,7 @@ private fun CreateRuleSetSheet(
|
||||
OutlinedTextField(
|
||||
value = ruleSetName,
|
||||
onValueChange = { ruleSetName = it },
|
||||
label = { Text("Rule set name") },
|
||||
label = { Text("Phase set name") },
|
||||
placeholder = { Text("e.g. House Rules") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -286,7 +368,7 @@ private fun CreateRuleSetSheet(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Edit phase rules:",
|
||||
"Edit phases:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -346,7 +428,7 @@ private fun CreateRuleSetSheet(
|
||||
) {
|
||||
Icon(Icons.Filled.Save, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Save Rule Set", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Save Phase Set", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,9 @@ fun GameSetupScreen(
|
||||
val allPlayers by vm.allPlayers.collectAsState()
|
||||
val selectedPlayers by vm.selectedPlayers.collectAsState()
|
||||
val newGameId by vm.newGameId.collectAsState()
|
||||
val customRuleSets by vm.customRuleSets.collectAsState()
|
||||
val selectedRuleSet by vm.selectedRuleSet.collectAsState()
|
||||
val customPhaseSets by vm.customPhaseSets.collectAsState()
|
||||
val selectedPhaseSet by vm.selectedPhaseSet.collectAsState()
|
||||
val presetPhaseSets = vm.presetPhaseSets
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var showRuleDropdown by remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
@@ -113,22 +114,37 @@ fun GameSetupScreen(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// ── Rules selector ───────────────────────────────────────────────
|
||||
// ── Phase set selector ───────────────────────────────────────────
|
||||
item(key = "rules_header") {
|
||||
Text(
|
||||
"Rules",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Phase",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
// Random phase-set picker
|
||||
FilledTonalIconButton(
|
||||
onClick = { vm.selectRandomPhaseSet() },
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Casino,
|
||||
contentDescription = "Pick random phase set"
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showRuleDropdown,
|
||||
onExpandedChange = { showRuleDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedRuleSet?.name ?: "Official Rules",
|
||||
value = selectedPhaseSet?.name ?: "Official Phases",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Phase rules") },
|
||||
label = { Text("Phase set") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showRuleDropdown) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -139,20 +155,62 @@ fun GameSetupScreen(
|
||||
expanded = showRuleDropdown,
|
||||
onDismissRequest = { showRuleDropdown = false }
|
||||
) {
|
||||
// ── Official ─────────────────────────────────────────
|
||||
DropdownMenuItem(
|
||||
text = { Text("Official Rules") },
|
||||
onClick = { vm.selectRuleSet(null); showRuleDropdown = false },
|
||||
leadingIcon = { if (selectedRuleSet == null) Icon(Icons.Filled.Check, null) }
|
||||
text = { Text("Official Phases") },
|
||||
onClick = { vm.selectPhaseSet(null); showRuleDropdown = false },
|
||||
leadingIcon = {
|
||||
if (selectedPhaseSet == null) Icon(Icons.Filled.Check, null)
|
||||
}
|
||||
)
|
||||
customRuleSets.forEach { ruleSet ->
|
||||
|
||||
// ── Presets ──────────────────────────────────────────
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"— Presets —",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
presetPhaseSets.forEach { ruleSet ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(ruleSet.name) },
|
||||
onClick = { vm.selectRuleSet(ruleSet); showRuleDropdown = false },
|
||||
onClick = { vm.selectPhaseSet(ruleSet); showRuleDropdown = false },
|
||||
leadingIcon = {
|
||||
if (selectedRuleSet?.id == ruleSet.id) Icon(Icons.Filled.Check, null)
|
||||
if (selectedPhaseSet?.id == ruleSet.id) Icon(Icons.Filled.Check, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── User custom ──────────────────────────────────────
|
||||
if (customPhaseSets.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"— Custom —",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
customPhaseSets.forEach { ruleSet ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(ruleSet.name) },
|
||||
onClick = { vm.selectPhaseSet(ruleSet); showRuleDropdown = false },
|
||||
leadingIcon = {
|
||||
if (selectedPhaseSet?.id == ruleSet.id) Icon(Icons.Filled.Check, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,25 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.crsmthw.phase10tracker.R
|
||||
import com.crsmthw.phase10tracker.data.ThemeMode
|
||||
import com.crsmthw.phase10tracker.ui.HomeViewModel
|
||||
|
||||
// ── Home Screen ────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
vm: HomeViewModel,
|
||||
themeMode: ThemeMode,
|
||||
amoledBlack: Boolean,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
onAmoledBlackChange: (Boolean) -> Unit,
|
||||
onContinueGame: (Long) -> Unit,
|
||||
onStartNew: () -> Unit,
|
||||
onLeaderboard: () -> Unit,
|
||||
@@ -32,9 +40,10 @@ fun HomeScreen(
|
||||
val hasActiveGame by vm.hasActiveGame.collectAsState()
|
||||
val activeGameId by vm.activeGameId.collectAsState()
|
||||
|
||||
// Dialog only shown when user explicitly taps "Game In Progress" button
|
||||
var showResumeDialog by remember { mutableStateOf(false) }
|
||||
var showThemeSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// ── Resume dialog ──────────────────────────────────────────────────────────
|
||||
if (showResumeDialog && hasActiveGame && activeGameId != null) {
|
||||
ResumeGameDialog(
|
||||
onContinue = {
|
||||
@@ -49,40 +58,57 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Theme bottom sheet ─────────────────────────────────────────────────────
|
||||
if (showThemeSheet) {
|
||||
ThemePickerSheet(
|
||||
currentMode = themeMode,
|
||||
amoledBlack = amoledBlack,
|
||||
onModeSelected = onThemeModeChange,
|
||||
onAmoledBlackChange = onAmoledBlackChange,
|
||||
onDismiss = { showThemeSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Phase 10",
|
||||
text = "Phase 10",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Theme picker button — icon reflects current mode
|
||||
IconButton(onClick = { showThemeSheet = true }) {
|
||||
Icon(
|
||||
imageVector = themeMode.icon(),
|
||||
contentDescription = "Display theme"
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onAbout) {
|
||||
Icon(Icons.Filled.Info, contentDescription = "About")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// App icon artwork used as hero image
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
@@ -91,16 +117,16 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "Phase 10\nScore Tracker",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
text = "Phase 10\nScore Tracker",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Ad-free. Open source. Always.",
|
||||
text = "Ad-free. Open source. Always.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -108,19 +134,19 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(56.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onClick = {
|
||||
if (hasActiveGame) showResumeDialog = true
|
||||
else onStartNew()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.large
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (hasActiveGame) "Game In Progress" else "Start New Game",
|
||||
text = if (hasActiveGame) "Game In Progress" else "Start New Game",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
@@ -128,11 +154,11 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onLeaderboard,
|
||||
onClick = onLeaderboard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.large
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Icon(Icons.Outlined.EmojiEvents, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@@ -142,7 +168,7 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = onManagePlayers,
|
||||
onClick = onManagePlayers,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Outlined.Group, contentDescription = null)
|
||||
@@ -153,17 +179,161 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = onCustomRules,
|
||||
onClick = onCustomRules,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.Tune, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Custom Rules", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Custom Phases", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme icon helper ──────────────────────────────────────────────────────────
|
||||
// The TopAppBar icon reflects the currently-active mode.
|
||||
|
||||
private fun ThemeMode.icon(): ImageVector = when (this) {
|
||||
ThemeMode.LIGHT -> Icons.Outlined.LightMode
|
||||
ThemeMode.DARK -> Icons.Outlined.DarkMode
|
||||
ThemeMode.SYSTEM -> Icons.Outlined.BrightnessMedium
|
||||
}
|
||||
|
||||
// ── Theme Picker BottomSheet ───────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ThemePickerSheet(
|
||||
currentMode: ThemeMode,
|
||||
amoledBlack: Boolean,
|
||||
onModeSelected: (ThemeMode) -> Unit,
|
||||
onAmoledBlackChange: (Boolean) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Palette,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Display Theme",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mode selector (segmented buttons) ─────────────────────────────
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Mode",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ThemeModeOption.entries.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
selected = currentMode == option.mode,
|
||||
onClick = { onModeSelected(option.mode) },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = ThemeModeOption.entries.size
|
||||
),
|
||||
icon = {
|
||||
SegmentedButtonDefaults.Icon(active = currentMode == option.mode) {
|
||||
Icon(
|
||||
imageVector = option.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(option.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
|
||||
// ── AMOLED toggle ─────────────────────────────────────────────────
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Contrast,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "AMOLED Pure Black",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = amoledBlack,
|
||||
onCheckedChange = onAmoledBlackChange
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Replaces dark backgrounds with true black — saves battery on OLED screens",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 34.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Segmented button data ──────────────────────────────────────────────────────
|
||||
|
||||
private enum class ThemeModeOption(
|
||||
val mode: ThemeMode,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
SYSTEM(ThemeMode.SYSTEM, "Auto", Icons.Outlined.BrightnessMedium),
|
||||
LIGHT (ThemeMode.LIGHT, "Light", Icons.Outlined.LightMode),
|
||||
DARK (ThemeMode.DARK, "Dark", Icons.Outlined.DarkMode),
|
||||
}
|
||||
|
||||
// ── Resume Game Dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ResumeGameDialog(
|
||||
onContinue: () -> Unit,
|
||||
@@ -174,16 +344,16 @@ private fun ResumeGameDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"10",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -204,4 +374,4 @@ private fun ResumeGameDialog(
|
||||
OutlinedButton(onClick = onStartNew) { Text("New Game") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.crsmthw.phase10tracker.data.model.PhaseRule
|
||||
import com.crsmthw.phase10tracker.data.model.RoundEntry
|
||||
import com.crsmthw.phase10tracker.data.model.getPhaseRule
|
||||
import com.crsmthw.phase10tracker.ui.RoundEntryViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -28,9 +28,10 @@ fun RoundEntryScreen(
|
||||
onRoundSubmitted: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val entries by vm.entries.collectAsState()
|
||||
val gameState by vm.gameState.collectAsState()
|
||||
val submitted by vm.submitted.collectAsState()
|
||||
val entries by vm.entries.collectAsState()
|
||||
val gameState by vm.gameState.collectAsState()
|
||||
val submitted by vm.submitted.collectAsState()
|
||||
val phaseRules by vm.phaseRules.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var showCardValues by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -102,6 +103,7 @@ fun RoundEntryScreen(
|
||||
items(entries, key = { it.gamePlayerId }) { entry ->
|
||||
RoundEntryCard(
|
||||
entry = entry,
|
||||
phaseRules = phaseRules,
|
||||
isLast = entry == entries.last(),
|
||||
onScoreChange = { vm.updateScore(entry.gamePlayerId, it) },
|
||||
onTogglePhase = { vm.togglePhaseCompleted(entry.gamePlayerId) },
|
||||
@@ -118,13 +120,16 @@ fun RoundEntryScreen(
|
||||
@Composable
|
||||
private fun RoundEntryCard(
|
||||
entry: RoundEntry,
|
||||
phaseRules: List<PhaseRule>,
|
||||
isLast: Boolean,
|
||||
onScoreChange: (String) -> Unit,
|
||||
onTogglePhase: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
val phaseRule = remember(entry.currentPhase) { getPhaseRule(entry.currentPhase) }
|
||||
val phaseRule = remember(entry.currentPhase, phaseRules) {
|
||||
phaseRules.getOrElse(entry.currentPhase - 1) { phaseRules.last() }
|
||||
}
|
||||
val scoreInt = entry.scoreInput.trim().toIntOrNull()
|
||||
|
||||
Card(
|
||||
|
||||
@@ -1,85 +1,213 @@
|
||||
package com.crsmthw.phase10tracker.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.crsmthw.phase10tracker.data.ThemeMode
|
||||
|
||||
// ── Custom fallback palette (used on devices below Android 12) ───────────────
|
||||
// ── Custom fallback palette (used on devices below Android 12) ─────────────────
|
||||
// Deep indigo + amber accent — bold, game-night feel
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFF9B8DFF), // soft violet
|
||||
onPrimary = Color(0xFF1A0070),
|
||||
primaryContainer = Color(0xFF2D1FA8),
|
||||
onPrimaryContainer = Color(0xFFD8D0FF),
|
||||
primary = Color(0xFF9B8DFF), // soft violet
|
||||
onPrimary = Color(0xFF1A0070),
|
||||
primaryContainer = Color(0xFF2D1FA8),
|
||||
onPrimaryContainer = Color(0xFFD8D0FF),
|
||||
|
||||
secondary = Color(0xFFFFBB33), // warm amber
|
||||
onSecondary = Color(0xFF3D2800),
|
||||
secondaryContainer = Color(0xFF593D00),
|
||||
secondary = Color(0xFFFFBB33), // warm amber
|
||||
onSecondary = Color(0xFF3D2800),
|
||||
secondaryContainer = Color(0xFF593D00),
|
||||
onSecondaryContainer = Color(0xFFFFDFA0),
|
||||
|
||||
tertiary = Color(0xFF5CE0C0), // teal
|
||||
onTertiary = Color(0xFF003730),
|
||||
tertiary = Color(0xFF5CE0C0), // teal
|
||||
onTertiary = Color(0xFF003730),
|
||||
tertiaryContainer = Color(0xFF004E43),
|
||||
onTertiaryContainer = Color(0xFF77FADA),
|
||||
|
||||
background = Color(0xFF0E0E18),
|
||||
onBackground = Color(0xFFE8E5FF),
|
||||
surface = Color(0xFF141425),
|
||||
onSurface = Color(0xFFE8E5FF),
|
||||
surfaceVariant = Color(0xFF1F1F38),
|
||||
onSurfaceVariant = Color(0xFFBBB8D4),
|
||||
background = Color(0xFF0E0E18),
|
||||
onBackground = Color(0xFFE8E5FF),
|
||||
surface = Color(0xFF141425),
|
||||
onSurface = Color(0xFFE8E5FF),
|
||||
surfaceVariant = Color(0xFF1F1F38),
|
||||
onSurfaceVariant = Color(0xFFBBB8D4),
|
||||
|
||||
error = Color(0xFFFF6B6B),
|
||||
onError = Color(0xFF690005),
|
||||
outline = Color(0xFF6B6890),
|
||||
outlineVariant = Color(0xFF3A3860),
|
||||
inverseSurface = Color(0xFFE8E5FF),
|
||||
inverseOnSurface = Color(0xFF1A1830),
|
||||
inversePrimary = Color(0xFF3A2FBF),
|
||||
|
||||
error = Color(0xFFFF6B6B),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
|
||||
scrim = Color(0xFF000000),
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF3A2FBF),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFD8D0FF),
|
||||
onPrimaryContainer = Color(0xFF1A0070),
|
||||
primary = Color(0xFF3A2FBF),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFD8D0FF),
|
||||
onPrimaryContainer = Color(0xFF1A0070),
|
||||
|
||||
secondary = Color(0xFF8A5E00),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFFFDFA0),
|
||||
secondary = Color(0xFF8A5E00),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFFFDFA0),
|
||||
onSecondaryContainer = Color(0xFF3D2800),
|
||||
|
||||
tertiary = Color(0xFF006B5E),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiary = Color(0xFF006B5E),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFB8F0E6),
|
||||
onTertiaryContainer = Color(0xFF004E43),
|
||||
|
||||
background = Color(0xFFF5F3FF),
|
||||
onBackground = Color(0xFF1A1830),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF1A1830),
|
||||
surfaceVariant = Color(0xFFEAE6FF),
|
||||
onSurfaceVariant = Color(0xFF4A4670),
|
||||
background = Color(0xFFF5F3FF),
|
||||
onBackground = Color(0xFF1A1830),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF1A1830),
|
||||
surfaceVariant = Color(0xFFEAE6FF),
|
||||
onSurfaceVariant = Color(0xFF4A4670),
|
||||
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
outline = Color(0xFF7B78A0),
|
||||
outlineVariant = Color(0xFFCCC9E8),
|
||||
inverseSurface = Color(0xFF302E47),
|
||||
inverseOnSurface = Color(0xFFF5F3FF),
|
||||
inversePrimary = Color(0xFF9B8DFF),
|
||||
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
|
||||
scrim = Color(0xFF000000),
|
||||
)
|
||||
|
||||
// ── AMOLED Pure Black overlay ──────────────────────────────────────────────────
|
||||
// Applied on top of whichever dark scheme is active (static or dynamic).
|
||||
// Overrides only the surface / background tokens so accent colours are preserved.
|
||||
|
||||
private fun ColorScheme.withAmoledBlack(): ColorScheme = copy(
|
||||
background = Color(0xFF000000),
|
||||
onBackground = Color(0xFFE8E5FF),
|
||||
surface = Color(0xFF000000),
|
||||
onSurface = Color(0xFFE8E5FF),
|
||||
surfaceVariant = Color(0xFF0D0D14),
|
||||
surfaceTint = Color(0xFF9B8DFF),
|
||||
inverseSurface = Color(0xFFE8E5FF),
|
||||
inverseOnSurface = Color(0xFF000000),
|
||||
)
|
||||
|
||||
// ── Smooth animated ColorScheme ────────────────────────────────────────────────
|
||||
// Animates every individual colour token so transitions between
|
||||
// light <-> dark <-> AMOLED are smooth rather than abrupt.
|
||||
|
||||
private val colorAnimSpec: AnimationSpec<Color> =
|
||||
tween(durationMillis = 450, easing = FastOutSlowInEasing)
|
||||
|
||||
@Composable
|
||||
private fun animatedColorScheme(target: ColorScheme): ColorScheme {
|
||||
|
||||
@Composable
|
||||
fun ac(c: Color): Color = animateColorAsState(c, colorAnimSpec, label = "").value
|
||||
|
||||
return target.copy(
|
||||
primary = ac(target.primary),
|
||||
onPrimary = ac(target.onPrimary),
|
||||
primaryContainer = ac(target.primaryContainer),
|
||||
onPrimaryContainer = ac(target.onPrimaryContainer),
|
||||
secondary = ac(target.secondary),
|
||||
onSecondary = ac(target.onSecondary),
|
||||
secondaryContainer = ac(target.secondaryContainer),
|
||||
onSecondaryContainer = ac(target.onSecondaryContainer),
|
||||
tertiary = ac(target.tertiary),
|
||||
onTertiary = ac(target.onTertiary),
|
||||
tertiaryContainer = ac(target.tertiaryContainer),
|
||||
onTertiaryContainer = ac(target.onTertiaryContainer),
|
||||
background = ac(target.background),
|
||||
onBackground = ac(target.onBackground),
|
||||
surface = ac(target.surface),
|
||||
onSurface = ac(target.onSurface),
|
||||
surfaceVariant = ac(target.surfaceVariant),
|
||||
onSurfaceVariant = ac(target.onSurfaceVariant),
|
||||
surfaceTint = ac(target.surfaceTint),
|
||||
inverseSurface = ac(target.inverseSurface),
|
||||
inverseOnSurface = ac(target.inverseOnSurface),
|
||||
inversePrimary = ac(target.inversePrimary),
|
||||
error = ac(target.error),
|
||||
onError = ac(target.onError),
|
||||
errorContainer = ac(target.errorContainer),
|
||||
onErrorContainer = ac(target.onErrorContainer),
|
||||
outline = ac(target.outline),
|
||||
outlineVariant = ac(target.outlineVariant),
|
||||
scrim = ac(target.scrim),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public theme entry-point ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun Phase10Theme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
amoledBlack: Boolean = false,
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
val systemInDark = isSystemInDarkTheme()
|
||||
|
||||
val isDark = when (themeMode) {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
ThemeMode.SYSTEM -> systemInDark
|
||||
}
|
||||
|
||||
// Resolve base scheme (dynamic on API 31+, static fallback below)
|
||||
val baseScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
isDark -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
// Overlay AMOLED black on top of whichever dark scheme we resolved
|
||||
val targetScheme = if (amoledBlack && isDark) baseScheme.withAmoledBlack() else baseScheme
|
||||
|
||||
// Every colour token animates individually -- buttery-smooth transitions
|
||||
val colorScheme = animatedColorScheme(targetScheme)
|
||||
|
||||
// ── Keep system bar icon tint in sync with the active theme ───────────────
|
||||
// SideEffect fires after every successful composition, so it stays in sync
|
||||
// even after navigating away or dismissing overlays (sheets, dialogs, etc.)
|
||||
// that temporarily take over the window appearance.
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
// Light bars = dark icons (readable on light backgrounds)
|
||||
// Dark bars = light icons (readable on dark backgrounds)
|
||||
isAppearanceLightStatusBars = !isDark
|
||||
isAppearanceLightNavigationBars = !isDark
|
||||
}
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Phase10Typography,
|
||||
shapes = Phase10Shapes,
|
||||
content = content
|
||||
typography = Phase10Typography,
|
||||
shapes = Phase10Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user