v3.0.0 - Preset Phases and Better Dark Mode

This commit is contained in:
2026-05-17 09:30:40 +03:00
parent 8eb3ea5c88
commit 383da18391
537 changed files with 9076 additions and 8562 deletions
@@ -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
}
@@ -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
)
}