Initial commit

This commit is contained in:
2026-05-15 10:03:12 +03:00
commit 04b11797d0
1115 changed files with 38810 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Phase 10 Tracker"
android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true"
android:theme="@style/Theme.Phase10Tracker"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,31 @@
package com.crsmthw.phase10tracker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import com.crsmthw.phase10tracker.ui.Phase10NavHost
import com.crsmthw.phase10tracker.ui.theme.Phase10Theme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Phase10Theme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
Phase10NavHost(navController = navController)
}
}
}
}
}
@@ -0,0 +1,117 @@
package com.crsmthw.phase10tracker.data.db
import androidx.room.*
import com.crsmthw.phase10tracker.data.model.*
import kotlinx.coroutines.flow.Flow
// ── Player DAO ───────────────────────────────────────────────────────────────
@Dao
interface PlayerDao {
@Query("SELECT * FROM players ORDER BY name ASC")
fun getAllPlayers(): Flow<List<PlayerEntity>>
@Query("SELECT * FROM players WHERE id = :id")
suspend fun getPlayerById(id: Long): PlayerEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPlayer(player: PlayerEntity): Long
@Update
suspend fun updatePlayer(player: PlayerEntity)
@Delete
suspend fun deletePlayer(player: PlayerEntity)
@Query("UPDATE players SET gamesPlayed = gamesPlayed + 1 WHERE id IN (:ids)")
suspend fun incrementGamesPlayed(ids: List<Long>)
@Query("UPDATE players SET gamesWon = gamesWon + 1 WHERE id = :id")
suspend fun incrementGamesWon(id: Long)
}
// ── Game DAO ─────────────────────────────────────────────────────────────────
@Dao
interface GameDao {
@Query("UPDATE games SET isComplete = 1, finishedAt = :now WHERE isComplete = 0")
suspend fun cancelAllIncompleteGames(now: Long = System.currentTimeMillis())
@Query("SELECT * FROM games WHERE isComplete = 0 ORDER BY startedAt DESC LIMIT 1")
suspend fun getActiveGame(): GameEntity?
@Query("SELECT * FROM games WHERE isComplete = 0 ORDER BY startedAt DESC LIMIT 1")
fun observeActiveGame(): Flow<GameEntity?>
@Query("SELECT * FROM games WHERE id = :id")
fun getGameById(id: Long): Flow<GameEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGame(game: GameEntity): Long
@Update
suspend fun updateGame(game: GameEntity)
@Query("UPDATE games SET currentRound = :round, currentDealerIndex = :dealerIndex WHERE id = :id")
suspend fun advanceRound(id: Long, round: Int, dealerIndex: Int)
@Query("""
UPDATE games
SET isComplete = 1, finishedAt = :finishedAt, winnerId = :winnerId
WHERE id = :id
""")
suspend fun finishGame(id: Long, winnerId: Long, finishedAt: Long = System.currentTimeMillis())
}
// ── GamePlayer DAO ───────────────────────────────────────────────────────────
@Dao
interface GamePlayerDao {
@Query("SELECT * FROM game_players WHERE gameId = :gameId ORDER BY turnOrder ASC")
fun getGamePlayers(gameId: Long): Flow<List<GamePlayerEntity>>
@Query("SELECT * FROM game_players WHERE gameId = :gameId ORDER BY turnOrder ASC")
suspend fun getGamePlayersList(gameId: Long): List<GamePlayerEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGamePlayers(players: List<GamePlayerEntity>)
@Update
suspend fun updateGamePlayer(player: GamePlayerEntity)
@Update
suspend fun updateGamePlayers(players: List<GamePlayerEntity>)
@Query("SELECT * FROM game_players WHERE id = :id")
suspend fun getGamePlayerById(id: Long): GamePlayerEntity?
}
// ── CustomRuleSet DAO ─────────────────────────────────────────────────────────
@Dao
interface CustomRuleSetDao {
@Query("SELECT * FROM custom_rule_sets ORDER BY createdAt DESC")
fun getAllRuleSets(): Flow<List<CustomRuleSetEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRuleSet(ruleSet: CustomRuleSetEntity): Long
@Delete
suspend fun deleteRuleSet(ruleSet: CustomRuleSetEntity)
}
@Dao
interface RoundDao {
@Query("SELECT * FROM rounds WHERE gameId = :gameId ORDER BY roundNumber ASC")
fun getRoundsForGame(gameId: Long): Flow<List<RoundEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRounds(rounds: List<RoundEntity>)
@Query("SELECT * FROM rounds WHERE gamePlayerId = :gamePlayerId ORDER BY roundNumber ASC")
suspend fun getRoundsForPlayer(gamePlayerId: Long): List<RoundEntity>
}
@@ -0,0 +1,45 @@
package com.crsmthw.phase10tracker.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.crsmthw.phase10tracker.data.model.*
@Database(
entities = [
PlayerEntity::class,
GameEntity::class,
GamePlayerEntity::class,
RoundEntity::class,
CustomRuleSetEntity::class
],
version = 2,
exportSchema = false
)
abstract class Phase10Database : RoomDatabase() {
abstract fun playerDao(): PlayerDao
abstract fun gameDao(): GameDao
abstract fun gamePlayerDao(): GamePlayerDao
abstract fun roundDao(): RoundDao
abstract fun customRuleSetDao(): CustomRuleSetDao
companion object {
@Volatile
private var INSTANCE: Phase10Database? = null
fun getInstance(context: Context): Phase10Database {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
Phase10Database::class.java,
"phase10_tracker.db"
)
.fallbackToDestructiveMigration(true)
.build()
.also { INSTANCE = it }
}
}
}
}
@@ -0,0 +1,39 @@
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)
}
}
@@ -0,0 +1,99 @@
package com.crsmthw.phase10tracker.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// ── Saved player roster ──────────────────────────────────────────────────────
@Entity(tableName = "players")
data class PlayerEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val gamesPlayed: Int = 0,
val gamesWon: Int = 0,
val createdAt: Long = System.currentTimeMillis()
)
// ── A single game session ────────────────────────────────────────────────────
@Entity(tableName = "games")
data class GameEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startedAt: Long = System.currentTimeMillis(),
val finishedAt: Long? = null,
val isComplete: Boolean = false,
val winnerId: Long? = null, // references PlayerEntity.id
val currentRound: Int = 1,
val currentDealerIndex: Int = 0 // index into the ordered player list
)
// ── Per-player state within a game ──────────────────────────────────────────
@Entity(
tableName = "game_players",
foreignKeys = [
ForeignKey(
entity = GameEntity::class,
parentColumns = ["id"],
childColumns = ["gameId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = PlayerEntity::class,
parentColumns = ["id"],
childColumns = ["playerId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("gameId"), Index("playerId")]
)
data class GamePlayerEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val gameId: Long,
val playerId: Long,
val playerName: String, // denormalized for display after roster changes
val turnOrder: Int, // 0-based, determines dealer rotation
val currentPhase: Int = 1,
val totalScore: Int = 0,
val isEliminated: Boolean = false // completed Phase 10 — still tracked
)
// ── Custom rule sets ─────────────────────────────────────────────────────────
@Entity(tableName = "custom_rule_sets")
data class CustomRuleSetEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val rulesJson: String, // JSON array of PhaseRule serialized
val createdAt: Long = System.currentTimeMillis()
)
@Entity(
tableName = "rounds",
foreignKeys = [
ForeignKey(
entity = GameEntity::class,
parentColumns = ["id"],
childColumns = ["gameId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = GamePlayerEntity::class,
parentColumns = ["id"],
childColumns = ["gamePlayerId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("gameId"), Index("gamePlayerId")]
)
data class RoundEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val gameId: Long,
val gamePlayerId: Long,
val roundNumber: Int,
val score: Int,
val phaseCompleted: Boolean,
val phaseAtRoundStart: Int
)
@@ -0,0 +1,25 @@
package com.crsmthw.phase10tracker.data.model
data class PhaseRule(
val phaseNumber: Int,
val title: String,
val description: String
)
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."),
PhaseRule(3, "1 set of 4 + 1 run of 4", "One group of 4 same-number cards, plus 4 consecutive numbers."),
PhaseRule(4, "1 run of 7", "Seven consecutive numbers in any combination of colors."),
PhaseRule(5, "1 run of 8", "Eight consecutive numbers in any combination of colors."),
PhaseRule(6, "1 run of 9", "Nine consecutive numbers in any combination of colors."),
PhaseRule(7, "2 sets of 4", "Two groups of 4 cards with the same number."),
PhaseRule(8, "7 cards of 1 color", "Seven cards all of the same color."),
PhaseRule(9, "1 set of 5 + 1 set of 2", "One group of 5 same-number cards, plus a pair."),
PhaseRule(10, "1 set of 5 + 1 set of 3", "One group of 5 same-number cards, plus a group of 3.")
)
fun getPhaseRule(phase: Int, rules: List<PhaseRule> = OFFICIAL_PHASE_RULES): PhaseRule =
rules.getOrElse(phase - 1) { rules.last() }
const val PHASE_COMPLETE_THRESHOLD = 50
@@ -0,0 +1,47 @@
package com.crsmthw.phase10tracker.data.model
// Shown in the active game scoreboard
data class PlayerGameState(
val gamePlayerId: Long,
val playerId: Long,
val playerName: String,
val turnOrder: Int,
val currentPhase: Int,
val totalScore: Int,
val isDealer: Boolean = false,
val hasCompletedAllPhases: Boolean = false
)
// Used for the round-entry screen
data class RoundEntry(
val gamePlayerId: Long,
val playerName: String,
val currentPhase: Int,
val scoreInput: String = "",
val phaseCompleted: Boolean = false,
val autoCompleted: Boolean = false // true when score was auto-inferred < threshold
)
// Custom rule set UI model
data class CustomRuleSet(
val id: Long,
val name: String,
val phases: List<PhaseRule>
)
// Leaderboard row
data class LeaderboardEntry(
val playerId: Long,
val playerName: String,
val gamesPlayed: Int,
val gamesWon: Int,
val winPercentage: Float = if (gamesPlayed > 0) gamesWon.toFloat() / gamesPlayed else 0f
)
// End of game summary row
data class GameResult(
val playerName: String,
val finalScore: Int,
val finalPhase: Int,
val isWinner: Boolean
)
@@ -0,0 +1,264 @@
package com.crsmthw.phase10tracker.data.repository
import com.crsmthw.phase10tracker.data.db.*
import com.crsmthw.phase10tracker.data.model.*
import kotlinx.coroutines.flow.*
class GameRepository(
private val playerDao: PlayerDao,
private val gameDao: GameDao,
private val gamePlayerDao: GamePlayerDao,
private val roundDao: RoundDao,
private val customRuleSetDao: CustomRuleSetDao
) {
// ── Players ──────────────────────────────────────────────────────────────
fun getAllPlayers(): Flow<List<PlayerEntity>> = playerDao.getAllPlayers()
suspend fun addPlayer(name: String): Long =
playerDao.insertPlayer(PlayerEntity(name = name.trim()))
suspend fun deletePlayer(player: PlayerEntity) =
playerDao.deletePlayer(player)
// ── Active Game Check ────────────────────────────────────────────────────
suspend fun getActiveGame(): GameEntity? = gameDao.getActiveGame()
fun observeActiveGame(): Flow<GameEntity?> = gameDao.observeActiveGame()
// ── Start Game ───────────────────────────────────────────────────────────
suspend fun startNewGame(orderedPlayers: List<PlayerEntity>): Long {
// Wipe any lingering incomplete games before starting fresh
gameDao.cancelAllIncompleteGames()
val gameId = gameDao.insertGame(GameEntity())
val gamePlayers = orderedPlayers.mapIndexed { index, player ->
GamePlayerEntity(
gameId = gameId,
playerId = player.id,
playerName = player.name,
turnOrder = index,
currentPhase = 1,
totalScore = 0
)
}
gamePlayerDao.insertGamePlayers(gamePlayers)
return gameId
}
// ── Live Game State ──────────────────────────────────────────────────────
fun getGameById(gameId: Long): Flow<GameEntity?> = gameDao.getGameById(gameId)
fun getGamePlayers(gameId: Long): Flow<List<GamePlayerEntity>> =
gamePlayerDao.getGamePlayers(gameId)
fun getActiveBoardState(gameId: Long): Flow<List<PlayerGameState>> =
combine(
gameDao.getGameById(gameId),
gamePlayerDao.getGamePlayers(gameId)
) { game, players ->
val dealerIndex = game?.currentDealerIndex ?: 0
players.map { gp ->
PlayerGameState(
gamePlayerId = gp.id,
playerId = gp.playerId,
playerName = gp.playerName,
turnOrder = gp.turnOrder,
currentPhase = gp.currentPhase,
totalScore = gp.totalScore,
isDealer = gp.turnOrder == dealerIndex,
hasCompletedAllPhases = gp.currentPhase > 10
)
}.sortedWith(
compareByDescending<PlayerGameState> { it.currentPhase }
.thenBy { it.totalScore }
)
}
// ── Submit Round ─────────────────────────────────────────────────────────
suspend fun submitRound(gameId: Long, entries: List<RoundEntry>) {
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
val completed = entry.phaseCompleted
val newPhase = if (completed && gp.currentPhase <= 10) gp.currentPhase + 1
else gp.currentPhase
val newScore = gp.totalScore + score
// Update the game player
gamePlayerDao.updateGamePlayer(
gp.copy(
currentPhase = newPhase,
totalScore = newScore
)
)
RoundEntity(
gameId = gameId,
gamePlayerId = entry.gamePlayerId,
roundNumber = game.currentRound,
score = score,
phaseCompleted = completed,
phaseAtRoundStart = gp.currentPhase
)
}
roundDao.insertRounds(rounds)
// Advance round & dealer
val playerCount = gamePlayers.size
val nextDealerIndex = (game.currentDealerIndex + 1) % playerCount
gameDao.advanceRound(
id = gameId,
round = game.currentRound + 1,
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 })
}
}
// ── End Game ─────────────────────────────────────────────────────────────
suspend fun endGame(gameId: Long, winnerIds: List<Long>? = null) {
val gamePlayers = gamePlayerDao.getGamePlayersList(gameId)
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 }
topPlayers.filter { it.totalScore == lowestScore }.map { it.playerId }
}
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) }
}
// ── Results ──────────────────────────────────────────────────────────────
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 }
val winnerIds = topPlayers
.filter { it.totalScore == lowestScore }
.map { it.playerId }
.toSet()
// Sort: highest phase first, then lowest score (ascending)
return gamePlayers
.sortedWith(compareByDescending<GamePlayerEntity> { it.currentPhase }
.thenBy { it.totalScore })
.map { gp ->
GameResult(
playerName = gp.playerName,
finalScore = gp.totalScore,
finalPhase = minOf(gp.currentPhase, 10),
isWinner = gp.playerId in winnerIds
)
}
}
// ── Leaderboard ──────────────────────────────────────────────────────────
fun getLeaderboard(): Flow<List<LeaderboardEntry>> =
playerDao.getAllPlayers().map { players ->
players
.map { p ->
LeaderboardEntry(
playerId = p.id,
playerName = p.name,
gamesPlayed = p.gamesPlayed,
gamesWon = p.gamesWon
)
}
.sortedByDescending { it.winPercentage }
}
// ── 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 ──────────────────────────────────────────────────────
fun getAllCustomRuleSets(): Flow<List<CustomRuleSet>> =
customRuleSetDao.getAllRuleSets().map { entities ->
entities.map { entity ->
CustomRuleSet(
id = entity.id,
name = entity.name,
phases = parseRulesJson(entity.rulesJson)
)
}
}
suspend fun saveCustomRuleSet(name: String, phases: List<PhaseRule>): Long {
val json = buildRulesJson(phases)
return customRuleSetDao.insertRuleSet(
CustomRuleSetEntity(name = name.trim(), rulesJson = json)
)
}
suspend fun deleteCustomRuleSet(ruleSet: CustomRuleSet) {
customRuleSetDao.deleteRuleSet(
CustomRuleSetEntity(id = ruleSet.id, name = ruleSet.name, rulesJson = "")
)
}
// Simple JSON helpers (no external library needed for this flat structure)
private fun buildRulesJson(phases: List<PhaseRule>): String {
return phases.joinToString(",", "[", "]") { phase ->
"""{"n":${phase.phaseNumber},"t":"${phase.title.replace("\"", "'")}","d":"${phase.description.replace("\"", "'")}"}"""
}
}
private fun parseRulesJson(json: String): List<PhaseRule> {
return try {
val items = json.trim('[', ']').split("},")
items.mapIndexed { index, item ->
val clean = item.trim().trimEnd('}')
val n = Regex(""""n":(\d+)""").find(clean)?.groupValues?.get(1)?.toIntOrNull() ?: (index + 1)
val t = Regex(""""t":"([^"]+)"""").find(clean)?.groupValues?.get(1) ?: "Phase ${index + 1}"
val d = Regex(""""d":"([^"]+)"""").find(clean)?.groupValues?.get(1) ?: t
PhaseRule(n, t, d)
}
} catch (e: Exception) {
OFFICIAL_PHASE_RULES
}
}
}
@@ -0,0 +1,148 @@
package com.crsmthw.phase10tracker.ui
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.*
import androidx.navigation.navArgument
import com.crsmthw.phase10tracker.data.db.Phase10Database
import com.crsmthw.phase10tracker.data.repository.GameRepository
import com.crsmthw.phase10tracker.ui.screens.*
object Routes {
const val HOME = "home"
const val PLAYER_ROSTER = "players"
const val GAME_SETUP = "setup"
const val ACTIVE_GAME = "game/{gameId}"
const val ROUND_ENTRY = "round/{gameId}"
const val GAME_RESULTS = "results/{gameId}"
const val LEADERBOARD = "leaderboard"
const val CUSTOM_RULES = "custom_rules"
fun activeGame(id: Long) = "game/$id"
fun roundEntry(id: Long) = "round/$id"
fun gameResults(id: Long) = "results/$id"
}
@Composable
fun Phase10NavHost(navController: NavHostController) {
val context = LocalContext.current
val db = remember { Phase10Database.getInstance(context) }
val repo = remember {
GameRepository(
db.playerDao(),
db.gameDao(),
db.gamePlayerDao(),
db.roundDao(),
db.customRuleSetDao()
)
}
val factory = remember { ViewModelFactory(repo) }
NavHost(navController = navController, startDestination = Routes.HOME) {
composable(Routes.HOME) {
val vm: HomeViewModel = viewModel(factory = factory)
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) }
)
}
composable(Routes.PLAYER_ROSTER) {
val vm: PlayerRosterViewModel = viewModel(factory = factory)
PlayerRosterScreen(
vm = vm,
onBack = { navController.popBackStack() }
)
}
composable(Routes.GAME_SETUP) {
val vm: GameSetupViewModel = viewModel(factory = factory)
GameSetupScreen(
vm = vm,
onGameStarted = { gameId ->
navController.navigate(Routes.activeGame(gameId)) {
popUpTo(Routes.HOME)
}
},
onBack = { navController.popBackStack() }
)
}
composable(
route = Routes.ACTIVE_GAME,
arguments = listOf(navArgument("gameId") { type = NavType.LongType })
) { backStackEntry ->
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)
}},
onGameCancelled = {
navController.navigate(Routes.HOME) {
popUpTo(Routes.HOME) { inclusive = true }
}
},
onBack = { navController.popBackStack() }
)
}
composable(
route = Routes.ROUND_ENTRY,
arguments = listOf(navArgument("gameId") { type = NavType.LongType })
) { backStackEntry ->
val gameId = backStackEntry.arguments!!.getLong("gameId")
val gameFactory = remember { ViewModelFactory(repo, gameId) }
val vm: RoundEntryViewModel = viewModel(factory = gameFactory)
RoundEntryScreen(
vm = vm,
onRoundSubmitted = { navController.popBackStack() },
onBack = { navController.popBackStack() }
)
}
composable(
route = Routes.GAME_RESULTS,
arguments = listOf(navArgument("gameId") { type = NavType.LongType })
) { backStackEntry ->
val gameId = backStackEntry.arguments!!.getLong("gameId")
val gameFactory = remember { ViewModelFactory(repo, gameId) }
val vm: GameResultsViewModel = viewModel(factory = gameFactory)
GameResultsScreen(
vm = vm,
onHome = {
navController.navigate(Routes.HOME) {
popUpTo(Routes.HOME) { inclusive = true }
}
}
)
}
composable(Routes.LEADERBOARD) {
val vm: LeaderboardViewModel = viewModel(factory = factory)
LeaderboardScreen(
vm = vm,
onBack = { navController.popBackStack() }
)
}
composable(Routes.CUSTOM_RULES) {
val vm: CustomRulesViewModel = viewModel(factory = factory)
CustomRulesScreen(
vm = vm,
onBack = { navController.popBackStack() }
)
}
}
}
@@ -0,0 +1,291 @@
package com.crsmthw.phase10tracker.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.crsmthw.phase10tracker.data.model.*
import com.crsmthw.phase10tracker.data.repository.GameRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
// ── Home ViewModel ────────────────────────────────────────────────────────────
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)
val hasActiveGame: StateFlow<Boolean> = _activeGame
.map { it != null }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val activeGameId: StateFlow<Long?> = _activeGame
.map { it?.id }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
// ── Player Roster ViewModel ───────────────────────────────────────────────────
class PlayerRosterViewModel(private val repo: GameRepository) : ViewModel() {
val players: StateFlow<List<PlayerEntity>> = repo.getAllPlayers()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun addPlayer(name: String) {
if (name.isBlank()) return
viewModelScope.launch { repo.addPlayer(name) }
}
fun deletePlayer(player: PlayerEntity) {
viewModelScope.launch { repo.deletePlayer(player) }
}
}
// ── Game Setup ViewModel ──────────────────────────────────────────────────────
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()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _selectedPlayers = MutableStateFlow<List<PlayerEntity>>(emptyList())
val selectedPlayers: StateFlow<List<PlayerEntity>> = _selectedPlayers
private val _selectedRuleSet = MutableStateFlow<CustomRuleSet?>(null)
val selectedRuleSet: StateFlow<CustomRuleSet?> = _selectedRuleSet
fun selectRuleSet(ruleSet: CustomRuleSet?) {
_selectedRuleSet.value = ruleSet
}
fun togglePlayer(player: PlayerEntity) {
val current = _selectedPlayers.value.toMutableList()
if (current.any { it.id == player.id }) {
current.removeAll { it.id == player.id }
} else {
current.add(player)
}
_selectedPlayers.value = current
}
fun movePlayer(fromIndex: Int, toIndex: Int) {
_selectedPlayers.value = _selectedPlayers.value.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
}
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
}
}
private val _newGameId = MutableStateFlow<Long?>(null)
val newGameId: StateFlow<Long?> = _newGameId
fun startGame() {
val players = _selectedPlayers.value
if (players.size < 2) return
viewModelScope.launch {
val id = repo.startNewGame(players)
_newGameId.value = id
}
}
}
// ── Active Game ViewModel ─────────────────────────────────────────────────────
class ActiveGameViewModel(
private val repo: GameRepository,
private val gameId: Long
) : ViewModel() {
val boardState: StateFlow<List<PlayerGameState>> =
repo.getActiveBoardState(gameId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val gameState: StateFlow<GameEntity?> =
repo.getGameById(gameId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
private val _gameFinished = MutableStateFlow(false)
val gameFinished: StateFlow<Boolean> = _gameFinished
private val _gameCancelled = MutableStateFlow(false)
val gameCancelled: StateFlow<Boolean> = _gameCancelled
init {
viewModelScope.launch {
gameState.collect { game ->
// Only trigger gameFinished if we haven't already cancelled
if (game?.isComplete == true && !_gameCancelled.value) {
_gameFinished.value = true
}
}
}
}
fun endGameEarly() {
viewModelScope.launch {
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 {
repo.endGame(gameId)
_gameFinished.value = true
}
}
}
}
// ── Round Entry ViewModel ─────────────────────────────────────────────────────
class RoundEntryViewModel(
private val repo: GameRepository,
private val gameId: Long
) : ViewModel() {
private val _entries = MutableStateFlow<List<RoundEntry>>(emptyList())
val entries: StateFlow<List<RoundEntry>> = _entries
val gameState: StateFlow<GameEntity?> =
repo.getGameById(gameId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
viewModelScope.launch {
repo.getGamePlayers(gameId).collect { gamePlayers ->
if (_entries.value.isEmpty()) {
_entries.value = gamePlayers.map { gp ->
RoundEntry(
gamePlayerId = gp.id,
playerName = gp.playerName,
currentPhase = gp.currentPhase
)
}
}
}
}
}
fun updateScore(gamePlayerId: Long, score: String) {
_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)
)
entry.copy(
scoreInput = score,
phaseCompleted = if (autoComplete) true else if (scoreInt != null && scoreInt >= PHASE_COMPLETE_THRESHOLD) false else entry.phaseCompleted,
autoCompleted = autoComplete
)
}
}
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)
}
}
private val _submitted = MutableStateFlow(false)
val submitted: StateFlow<Boolean> = _submitted
fun submitRound() {
viewModelScope.launch {
repo.submitRound(gameId, _entries.value)
_submitted.value = true
}
}
fun isValid(): Boolean = _entries.value.all {
it.scoreInput.trim().toIntOrNull() != null
}
}
// ── Game Results ViewModel ────────────────────────────────────────────────────
class GameResultsViewModel(
private val repo: GameRepository,
private val gameId: Long
) : ViewModel() {
private val _results = MutableStateFlow<List<GameResult>>(emptyList())
val results: StateFlow<List<GameResult>> = _results
init {
viewModelScope.launch {
_results.value = repo.getGameResults(gameId)
}
}
}
// ── Leaderboard ViewModel ─────────────────────────────────────────────────────
class LeaderboardViewModel(private val repo: GameRepository) : ViewModel() {
val leaderboard: StateFlow<List<LeaderboardEntry>> = repo.getLeaderboard()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
// ── Custom Rules ViewModel ────────────────────────────────────────────────────
class CustomRulesViewModel(private val repo: GameRepository) : ViewModel() {
val customRuleSets: StateFlow<List<CustomRuleSet>> = repo.getAllCustomRuleSets()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun saveRuleSet(name: String, phases: List<PhaseRule>) {
if (name.isBlank() || phases.isEmpty()) return
viewModelScope.launch { repo.saveCustomRuleSet(name, phases) }
}
fun deleteRuleSet(ruleSet: CustomRuleSet) {
viewModelScope.launch { repo.deleteCustomRuleSet(ruleSet) }
}
}
// ── ViewModel Factory ─────────────────────────────────────────────────────────
class ViewModelFactory(
private val repo: GameRepository,
private val gameId: Long = -1L
) : ViewModelProvider.Factory {
@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
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
}
@@ -0,0 +1,468 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.PlayerGameState
import com.crsmthw.phase10tracker.data.model.getPhaseRule
import com.crsmthw.phase10tracker.ui.ActiveGameViewModel
private enum class ViewMode { SCORES, PHASES }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActiveGameScreen(
vm: ActiveGameViewModel,
onEnterRound: () -> Unit,
onGameEnd: () -> Unit,
onGameCancelled: () -> Unit,
onBack: () -> Unit
) {
val boardState by vm.boardState.collectAsState()
val gameState by vm.gameState.collectAsState()
val gameFinished by vm.gameFinished.collectAsState()
val gameCancelled by vm.gameCancelled.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(
onDismissRequest = { showEndGameDialog = false },
icon = { Icon(Icons.Filled.Flag, null) },
title = { Text("End Game Early?") },
text = {
Text(
if (allZero)
"No rounds have been played yet. The game will be cancelled with no winner recorded."
else
"The current leader (highest phase, lowest score) will be declared the winner. This cannot be undone."
)
},
confirmButton = {
Button(
onClick = { vm.endGameEarly(); showEndGameDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) { Text(if (boardState.all { it.totalScore == 0 }) "Cancel Game" else "End Game") }
},
dismissButton = {
OutlinedButton(onClick = { showEndGameDialog = false }) { Text("Cancel") }
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("Round ${gameState?.currentRound ?: 1}")
Text(
"${boardState.size} players",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { showEndGameDialog = true }) {
Icon(Icons.Filled.Flag, "End Game")
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onEnterRound,
icon = { Icon(Icons.Filled.Edit, null) },
text = { Text("Enter Round ${gameState?.currentRound ?: 1}") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
) { 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)
}
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)
}
}
} else {
// ── Compact layout: single pane with tab switcher ─────────────────
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
PrimaryTabRow(selectedTabIndex = viewMode.ordinal) {
Tab(
selected = viewMode == ViewMode.SCORES,
onClick = { viewMode = ViewMode.SCORES },
text = { Text("Scores") },
icon = { Icon(Icons.Filled.Leaderboard, null, Modifier.size(18.dp)) }
)
Tab(
selected = viewMode == ViewMode.PHASES,
onClick = { viewMode = ViewMode.PHASES },
text = { Text("By Phase") },
icon = { Icon(Icons.Filled.GridView, null, Modifier.size(18.dp)) }
)
}
AnimatedContent(
targetState = viewMode,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "viewMode"
) { mode ->
when (mode) {
ViewMode.SCORES -> ScoresView(boardState, fabClearance = true)
ViewMode.PHASES -> PhasesView(boardState, fabClearance = true)
}
}
}
}
}
}
// ── Pane header (expanded layout only) ───────────────────────────────────────
@Composable
private fun PaneSectionHeader(title: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
}
// ── Scores pane ───────────────────────────────────────────────────────────────
@Composable
private fun ScoresView(
boardState: List<PlayerGameState>,
fabClearance: Boolean
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(boardState, key = { _, p -> p.gamePlayerId }) { index, player ->
PlayerScoreCard(player = player, rank = index + 1)
}
if (fabClearance) item { Spacer(Modifier.height(80.dp)) }
}
}
// ── Phases pane ───────────────────────────────────────────────────────────────
@Composable
private fun PhasesView(
boardState: List<PlayerGameState>,
fabClearance: Boolean
) {
val grouped = boardState
.groupBy { minOf(it.currentPhase, 10) }
.toSortedMap()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
grouped.forEach { (phase, players) ->
val rule = getPhaseRule(phase)
item(key = "header_$phase") {
PhaseGroupHeader(phase = phase, rule = rule.title)
}
items(players, key = { it.gamePlayerId }) { player ->
PhaseGroupPlayerCard(player = player)
}
}
if (fabClearance) item { Spacer(Modifier.height(80.dp)) }
}
}
// ── Phase group header ────────────────────────────────────────────────────────
@Composable
private fun PhaseGroupHeader(phase: Int, rule: String) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
"P$phase",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondary
)
}
}
Spacer(Modifier.width(12.dp))
Text(
text = rule,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
// ── Phase group player card ───────────────────────────────────────────────────
@Composable
private fun PhaseGroupPlayerCard(player: PlayerGameState) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
player.playerName.take(1).uppercase(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(player.playerName, style = MaterialTheme.typography.titleMedium)
if (player.isDealer) {
Spacer(Modifier.width(6.dp))
DealerBadge()
}
}
}
Text(
"${player.totalScore} pts",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// ── Score card ────────────────────────────────────────────────────────────────
@Composable
private fun PlayerScoreCard(player: PlayerGameState, rank: Int) {
var expanded by remember { mutableStateOf(false) }
val phaseRule = remember(player.currentPhase) { getPhaseRule(player.currentPhase) }
val phase = minOf(player.currentPhase, 10)
val cardColor = when (rank) {
1 -> MaterialTheme.colorScheme.primaryContainer
2 -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val badgeColor = when (rank) {
1 -> MaterialTheme.colorScheme.primary
2 -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.tertiaryContainer
}
val badgeTextColor = when (rank) {
1 -> MaterialTheme.colorScheme.onPrimary
2 -> MaterialTheme.colorScheme.onSecondary
else -> MaterialTheme.colorScheme.onTertiaryContainer
}
Card(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(containerColor = cardColor),
elevation = CardDefaults.cardElevation(
defaultElevation = if (rank == 1) 4.dp else 1.dp
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = badgeColor,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
"$rank",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = badgeTextColor
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
player.playerName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (player.isDealer) {
Spacer(Modifier.width(6.dp))
DealerBadge()
}
}
}
Text(
"${player.totalScore}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.width(12.dp))
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.tertiaryContainer
) {
Text(
"P$phase",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
Spacer(Modifier.width(4.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
AnimatedVisibility(visible = expanded) {
Column {
Spacer(Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(10.dp))
Text(
"Phase $phase: ${phaseRule.title}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(4.dp))
Text(
phaseRule.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// ── Dealer badge ──────────────────────────────────────────────────────────────
@Composable
private fun DealerBadge() {
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiary
) {
Text(
" DEALER ",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiary,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
@@ -0,0 +1,353 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.OFFICIAL_PHASE_RULES
import com.crsmthw.phase10tracker.data.model.PhaseRule
import com.crsmthw.phase10tracker.ui.CustomRulesViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomRulesScreen(
vm: CustomRulesViewModel,
onBack: () -> Unit
) {
val ruleSets by vm.customRuleSets.collectAsState()
var showCreateSheet by remember { mutableStateOf(false) }
var ruleSetToDelete by remember { mutableStateOf<CustomRuleSet?>(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.") },
confirmButton = {
Button(
onClick = { vm.deleteRuleSet(rs); ruleSetToDelete = null },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
},
dismissButton = {
OutlinedButton(onClick = { ruleSetToDelete = null }) { Text("Cancel") }
}
)
}
if (showCreateSheet) {
CreateRuleSetSheet(
onSave = { name, phases ->
vm.saveRuleSet(name, phases)
showCreateSheet = false
},
onDismiss = { showCreateSheet = false }
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Custom Rules") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showCreateSheet = true },
icon = { Icon(Icons.Filled.Add, null) },
text = { Text("New Rule Set") }
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Official rules reference card
item {
Text(
"Official Rules (reference)",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 4.dp)
)
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
OFFICIAL_PHASE_RULES.forEachIndexed { index, rule ->
if (index > 0) HorizontalDivider(
modifier = Modifier.padding(vertical = 6.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(28.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
"${rule.phaseNumber}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(Modifier.width(10.dp))
Text(rule.title, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
if (ruleSets.isNotEmpty()) {
item {
Spacer(Modifier.height(4.dp))
Text(
"Your Custom Rule Sets",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 4.dp)
)
}
items(ruleSets, key = { it.id }) { ruleSet ->
CustomRuleSetCard(
ruleSet = ruleSet,
onDelete = { ruleSetToDelete = ruleSet }
)
}
} else {
item {
Spacer(Modifier.height(16.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
"No custom rule sets yet.\nTap + to create one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
@Composable
private fun CustomRuleSetCard(
ruleSet: CustomRuleSet,
onDelete: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Card(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
ruleSet.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
Text(
"${ruleSet.phases.size} phases",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(Modifier.width(8.dp))
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
null,
Modifier.size(20.dp)
)
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Filled.DeleteOutline,
"Delete",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
}
if (expanded) {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(8.dp))
ruleSet.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.primary,
modifier = Modifier.width(32.dp)
)
Text(
phase.title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
}
}
// ── Create Rule Set bottom sheet ──────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateRuleSetSheet(
onSave: (String, List<PhaseRule>) -> Unit,
onDismiss: () -> Unit
) {
var ruleSetName by remember { mutableStateOf("") }
// Start with official rules as template
val phases = remember {
mutableStateListOf(*OFFICIAL_PHASE_RULES.map { it.copy() }.toTypedArray())
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
contentWindowInsets = { WindowInsets.ime }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp)
.navigationBarsPadding()
) {
Text(
"New Rule Set",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = ruleSetName,
onValueChange = { ruleSetName = it },
label = { Text("Rule set name") },
placeholder = { Text("e.g. House Rules") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(16.dp))
Text(
"Edit phase rules:",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
// Scrollable phase list in a fixed-height box
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(phases) { index, phase ->
OutlinedTextField(
value = phase.title,
onValueChange = { phases[index] = phases[index].copy(title = it) },
label = { Text("Phase ${phase.phaseNumber}") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
leadingIcon = {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(28.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
"${phase.phaseNumber}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
)
}
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
if (ruleSetName.isNotBlank()) {
onSave(
ruleSetName.trim(),
phases.map { it.copy(description = it.title) }
)
}
},
enabled = ruleSetName.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = MaterialTheme.shapes.large
) {
Icon(Icons.Filled.Save, null)
Spacer(Modifier.width(8.dp))
Text("Save Rule Set", style = MaterialTheme.typography.titleMedium)
}
}
}
}
@@ -0,0 +1,202 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.crsmthw.phase10tracker.data.model.GameResult
import com.crsmthw.phase10tracker.ui.GameResultsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GameResultsScreen(
vm: GameResultsViewModel,
onHome: () -> Unit
) {
val results by vm.results.collectAsState()
// Pulse animation for the trophy
val infiniteTransition = rememberInfiniteTransition(label = "trophy")
val trophyScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.12f,
animationSpec = infiniteRepeatable(
animation = tween(900, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "trophyScale"
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Game Over") },
navigationIcon = {
IconButton(onClick = onHome) {
Icon(Icons.Filled.Home, "Home")
}
}
)
},
bottomBar = {
Surface(tonalElevation = 3.dp) {
Button(
onClick = onHome,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 16.dp)
.navigationBarsPadding()
.height(56.dp),
shape = MaterialTheme.shapes.large
) {
Icon(Icons.Filled.Home, null)
Spacer(Modifier.width(8.dp))
Text("Back to Home", style = MaterialTheme.typography.titleMedium)
}
}
}
) { padding ->
val winners = results.filter { it.isWinner }
val isTie = winners.size > 1
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Winner hero block
item {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
) {
Icon(
imageVector = Icons.Filled.EmojiEvents,
contentDescription = null,
modifier = Modifier
.size(96.dp)
.scale(trophyScale),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(Modifier.height(12.dp))
Text(
if (isTie) "It's a Tie!" else "Champion!",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
Text(
if (isTie) winners.joinToString(" & ") { it.playerName }
else winners.firstOrNull()?.playerName ?: "",
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
Text(
"Final score: ${winners.firstOrNull()?.finalScore ?: "-"}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
item {
HorizontalDivider()
Spacer(Modifier.height(4.dp))
Text(
"Final Standings",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth()
)
}
itemsIndexed(results, key = { i, _ -> i }) { index, result ->
ResultCard(result = result, rank = index + 1)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
@Composable
private fun ResultCard(result: GameResult, rank: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = if (result.isWinner)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Rank / trophy icon
Box(
modifier = Modifier.size(44.dp),
contentAlignment = Alignment.Center
) {
if (result.isWinner) {
Icon(
Icons.Filled.EmojiEvents,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(36.dp)
)
} else {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text("$rank", style = MaterialTheme.typography.titleSmall)
}
}
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(result.playerName, style = MaterialTheme.typography.titleMedium)
Text(
"Finished on Phase ${result.finalPhase}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"${result.finalScore} pts",
style = MaterialTheme.typography.titleMedium
)
}
}
}
@@ -0,0 +1,381 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.crsmthw.phase10tracker.data.model.PlayerEntity
import com.crsmthw.phase10tracker.ui.GameSetupViewModel
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GameSetupScreen(
vm: GameSetupViewModel,
onGameStarted: (Long) -> Unit,
onBack: () -> Unit
) {
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()
var showAddDialog by remember { mutableStateOf(false) }
var showRuleDropdown by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(newGameId) {
newGameId?.let { onGameStarted(it) }
}
if (showAddDialog) {
AddPlayerDialog(
onAdd = { name ->
vm.addAndSelectNewPlayer(name)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
val lazyListState = rememberLazyListState()
// The LazyColumn has several non-reorderable header items before the player list.
// Count them so we can subtract the offset when calling movePlayer.
// Headers: rules_header, players_header, player_chips, order_header = 4 items
// But order_header only appears when selectedPlayers is not empty.
// So offset = 3 (rules, select header, chips) + 1 (order header) = 4
val headerCount = if (selectedPlayers.isNotEmpty()) 4 else 3
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
// Subtract header offset to get actual player list indices
val fromPlayerIndex = from.index - headerCount
val toPlayerIndex = to.index - headerCount
if (fromPlayerIndex >= 0 && toPlayerIndex >= 0 &&
fromPlayerIndex < selectedPlayers.size && toPlayerIndex < selectedPlayers.size) {
vm.movePlayer(fromPlayerIndex, toPlayerIndex)
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("New Game") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
},
bottomBar = {
Surface(tonalElevation = 3.dp, shadowElevation = 8.dp) {
Button(
onClick = { vm.startGame() },
enabled = selectedPlayers.size >= 2,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 16.dp)
.navigationBarsPadding()
.height(56.dp),
shape = MaterialTheme.shapes.large
) {
Icon(Icons.Filled.PlayArrow, null)
Spacer(Modifier.width(8.dp))
Text(
"Start Game (${selectedPlayers.size} players)",
style = MaterialTheme.typography.titleMedium
)
}
}
}
) { padding ->
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Rules selector ───────────────────────────────────────────────
item(key = "rules_header") {
Text(
"Rules",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 4.dp)
)
ExposedDropdownMenuBox(
expanded = showRuleDropdown,
onExpandedChange = { showRuleDropdown = it }
) {
OutlinedTextField(
value = selectedRuleSet?.name ?: "Official Rules",
onValueChange = {},
readOnly = true,
label = { Text("Phase rules") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showRuleDropdown) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
shape = MaterialTheme.shapes.medium
)
ExposedDropdownMenu(
expanded = showRuleDropdown,
onDismissRequest = { showRuleDropdown = false }
) {
DropdownMenuItem(
text = { Text("Official Rules") },
onClick = { vm.selectRuleSet(null); showRuleDropdown = false },
leadingIcon = { if (selectedRuleSet == null) Icon(Icons.Filled.Check, null) }
)
customRuleSets.forEach { ruleSet ->
DropdownMenuItem(
text = { Text(ruleSet.name) },
onClick = { vm.selectRuleSet(ruleSet); showRuleDropdown = false },
leadingIcon = {
if (selectedRuleSet?.id == ruleSet.id) Icon(Icons.Filled.Check, null)
}
)
}
}
}
}
// ── Select Players ───────────────────────────────────────────────
item(key = "players_header") {
Spacer(Modifier.height(4.dp))
Text(
"Select Players",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
"Tap to add players to this game.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item(key = "player_chips") {
if (allPlayers.isEmpty()) {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"No saved players yet.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
TextButton(onClick = { showAddDialog = true }) {
Icon(Icons.Outlined.PersonAdd, null)
Spacer(Modifier.width(4.dp))
Text("Add your first player")
}
}
}
} else {
PlayerSelectionGrid(
allPlayers = allPlayers,
selectedPlayers = selectedPlayers,
onToggle = { vm.togglePlayer(it) },
onAddNew = { showAddDialog = true }
)
}
}
// ── Reorder section ──────────────────────────────────────────────
if (selectedPlayers.isNotEmpty()) {
item(key = "order_header") {
Spacer(Modifier.height(8.dp))
Text(
"Player Order & Dealer Rotation",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
"Hold the ≡ handle and drag to reorder. First player is the initial dealer.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
}
itemsIndexed(
items = selectedPlayers,
key = { _, p -> "player_${p.id}" }
) { index, player ->
ReorderableItem(
state = reorderableState,
key = "player_${player.id}"
) { isDragging ->
val elevation by animateDpAsState(
targetValue = if (isDragging) 8.dp else 0.dp,
label = "drag_elevation"
)
PlayerOrderCard(
player = player,
position = index + 1,
elevation = elevation,
onRemove = { vm.togglePlayer(player) }
)
}
}
}
item(key = "bottom_space") { Spacer(Modifier.height(16.dp)) }
}
}
}
@Composable
private fun sh.calvin.reorderable.ReorderableCollectionItemScope.PlayerOrderCard(
player: PlayerEntity,
position: Int,
elevation: androidx.compose.ui.unit.Dp,
onRemove: () -> Unit
) {
val hapticFeedback = LocalHapticFeedback.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = if (position == 1)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Drag handle — draggableHandle MUST be on IconButton, not Icon
IconButton(
onClick = {},
modifier = Modifier
.size(40.dp)
.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate
)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
)
) {
Icon(
imageVector = Icons.Filled.DragHandle,
contentDescription = "Drag to reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.width(4.dp))
// Position badge
Surface(
shape = MaterialTheme.shapes.small,
color = if (position == 1)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
"$position",
style = MaterialTheme.typography.titleSmall,
color = if (position == 1)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(player.name, style = MaterialTheme.typography.titleMedium)
if (position == 1) {
Text(
"First dealer",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Filled.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PlayerSelectionGrid(
allPlayers: List<PlayerEntity>,
selectedPlayers: List<PlayerEntity>,
onToggle: (PlayerEntity) -> Unit,
onAddNew: () -> Unit
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
allPlayers.forEach { player ->
val selected = selectedPlayers.any { it.id == player.id }
FilterChip(
selected = selected,
onClick = { onToggle(player) },
label = { Text(player.name) },
leadingIcon = if (selected) {
{ Icon(Icons.Filled.Check, null, Modifier.size(18.dp)) }
} else null,
shape = MaterialTheme.shapes.medium
)
}
AssistChip(
onClick = onAddNew,
label = { Text("New player") },
leadingIcon = { Icon(Icons.Outlined.PersonAdd, null, Modifier.size(18.dp)) },
shape = MaterialTheme.shapes.medium
)
}
}
@@ -0,0 +1,209 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.ui.HomeViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
vm: HomeViewModel,
onContinueGame: (Long) -> Unit,
onStartNew: () -> Unit,
onLeaderboard: () -> Unit,
onManagePlayers: () -> Unit,
onCustomRules: () -> Unit
) {
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) }
if (showResumeDialog && hasActiveGame && activeGameId != null) {
ResumeGameDialog(
onContinue = {
showResumeDialog = false
onContinueGame(activeGameId!!)
},
onStartNew = {
showResumeDialog = false
onStartNew()
},
onDismiss = { showResumeDialog = false }
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Phase 10",
style = MaterialTheme.typography.headlineMedium
)
},
actions = {
IconButton(onClick = onManagePlayers) {
Icon(Icons.Outlined.Group, contentDescription = "Manage Players")
}
IconButton(onClick = onLeaderboard) {
Icon(Icons.Outlined.EmojiEvents, contentDescription = "Leaderboard")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// App icon artwork used as hero image
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(120.dp)
.clip(MaterialTheme.shapes.extraLarge)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(Modifier.height(20.dp))
Text(
text = "Phase 10\nScore Tracker",
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
text = "Ad-free. Open source. Always.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(56.dp))
Button(
onClick = {
if (hasActiveGame) showResumeDialog = true
else onStartNew()
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
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",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onLeaderboard,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = MaterialTheme.shapes.large
) {
Icon(Icons.Outlined.EmojiEvents, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Leaderboard", style = MaterialTheme.typography.titleMedium)
}
Spacer(Modifier.height(12.dp))
TextButton(
onClick = onManagePlayers,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Outlined.Group, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Manage Saved Players", style = MaterialTheme.typography.titleMedium)
}
Spacer(Modifier.height(4.dp))
TextButton(
onClick = onCustomRules,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Tune, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Custom Rules", style = MaterialTheme.typography.titleMedium)
}
}
}
}
@Composable
private fun ResumeGameDialog(
onContinue: () -> Unit,
onStartNew: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
"10",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Black,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
},
title = {
Text("Game In Progress", style = MaterialTheme.typography.headlineSmall)
},
text = {
Text(
"You have an unfinished game. Do you want to continue it or start a new one?",
style = MaterialTheme.typography.bodyLarge
)
},
confirmButton = {
Button(onClick = onContinue) { Text("Continue") }
},
dismissButton = {
OutlinedButton(onClick = onStartNew) { Text("New Game") }
}
)
}
@@ -0,0 +1,171 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.crsmthw.phase10tracker.data.model.LeaderboardEntry
import com.crsmthw.phase10tracker.ui.LeaderboardViewModel
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LeaderboardScreen(
vm: LeaderboardViewModel,
onBack: () -> Unit
) {
val leaderboard by vm.leaderboard.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Leaderboard") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
if (leaderboard.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Filled.EmojiEvents,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
Spacer(Modifier.height(16.dp))
Text(
"No games played yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Play your first game to see stats here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
item {
Text(
"All time • Sorted by win %",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp)
)
}
itemsIndexed(leaderboard, key = { _, e -> e.playerId }) { index, entry ->
LeaderboardCard(entry = entry, rank = index + 1)
}
}
}
}
}
@Composable
private fun LeaderboardCard(
entry: LeaderboardEntry,
rank: Int
) {
val winPct = (entry.winPercentage * 100).roundToInt()
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = when (rank) {
1 -> MaterialTheme.colorScheme.primaryContainer
2, 3 -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Medal / rank
Box(
modifier = Modifier.size(44.dp),
contentAlignment = Alignment.Center
) {
when (rank) {
1 -> Icon(
Icons.Filled.EmojiEvents,
null,
Modifier.size(36.dp),
tint = MaterialTheme.colorScheme.secondary
)
2, 3 -> Icon(
Icons.Filled.EmojiEvents,
null,
Modifier.size(30.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
else -> Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text("$rank", style = MaterialTheme.typography.titleSmall)
}
}
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(entry.playerName, style = MaterialTheme.typography.titleMedium)
Text(
"${entry.gamesPlayed} game${if (entry.gamesPlayed != 1) "s" else ""} played",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
"$winPct%",
style = MaterialTheme.typography.titleLarge
)
Text(
"${entry.gamesWon} win${if (entry.gamesWon != 1) "s" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,227 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.crsmthw.phase10tracker.data.model.PlayerEntity
import com.crsmthw.phase10tracker.ui.PlayerRosterViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayerRosterScreen(
vm: PlayerRosterViewModel,
onBack: () -> Unit
) {
val players by vm.players.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
var playerToDelete by remember { mutableStateOf<PlayerEntity?>(null) }
if (showAddDialog) {
AddPlayerDialog(
onAdd = { name ->
vm.addPlayer(name)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
playerToDelete?.let { p ->
AlertDialog(
onDismissRequest = { playerToDelete = null },
icon = { Icon(Icons.Filled.PersonRemove, contentDescription = null) },
title = { Text("Remove Player?") },
text = { Text("Remove ${p.name} from your saved players? This won't affect game history.") },
confirmButton = {
Button(
onClick = { vm.deletePlayer(p); playerToDelete = null },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) { Text("Remove") }
},
dismissButton = {
OutlinedButton(onClick = { playerToDelete = null }) { Text("Cancel") }
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Saved Players") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
icon = { Icon(Icons.Outlined.PersonAdd, null) },
text = { Text("Add Player") }
)
}
) { padding ->
if (players.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Filled.Group,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(16.dp))
Text(
"No players yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Tap + to add your crew",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(players, key = { it.id }) { player ->
PlayerRosterCard(
player = player,
onDelete = { playerToDelete = player }
)
}
item { Spacer(Modifier.height(80.dp)) } // FAB clearance
}
}
}
}
@Composable
private fun PlayerRosterCard(
player: PlayerEntity,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(42.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = player.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(player.name, style = MaterialTheme.typography.titleMedium)
Text(
"${player.gamesPlayed} games · ${player.gamesWon} wins",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
fun AddPlayerDialog(
onAdd: (String) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Outlined.PersonAdd, null) },
title = { Text("Add Player") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Player name") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
capitalization = KeyboardCapitalization.Words
),
keyboardActions = KeyboardActions(
onDone = { if (name.isNotBlank()) onAdd(name.trim()) }
),
shape = MaterialTheme.shapes.medium
)
},
confirmButton = {
Button(
onClick = { if (name.isNotBlank()) onAdd(name.trim()) },
enabled = name.isNotBlank()
) { Text("Add") }
},
dismissButton = {
OutlinedButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@@ -0,0 +1,305 @@
package com.crsmthw.phase10tracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
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.RoundEntry
import com.crsmthw.phase10tracker.data.model.getPhaseRule
import com.crsmthw.phase10tracker.ui.RoundEntryViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoundEntryScreen(
vm: RoundEntryViewModel,
onRoundSubmitted: () -> Unit,
onBack: () -> Unit
) {
val entries by vm.entries.collectAsState()
val gameState by vm.gameState.collectAsState()
val submitted by vm.submitted.collectAsState()
val focusManager = LocalFocusManager.current
var showCardValues by remember { mutableStateOf(false) }
LaunchedEffect(submitted) {
if (submitted) onRoundSubmitted()
}
if (showCardValues) {
CardValuesDialog(onDismiss = { showCardValues = false })
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Round ${gameState?.currentRound ?: ""}") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { showCardValues = true }) {
Icon(Icons.Filled.Info, contentDescription = "Card point values")
}
}
)
},
bottomBar = {
Surface(tonalElevation = 3.dp, shadowElevation = 8.dp) {
Button(
onClick = {
focusManager.clearFocus()
vm.submitRound()
},
enabled = vm.isValid(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 16.dp)
.navigationBarsPadding()
.height(56.dp),
shape = MaterialTheme.shapes.large
) {
Icon(Icons.Filled.Check, null)
Spacer(Modifier.width(8.dp))
Text("Submit Round", style = MaterialTheme.typography.titleMedium)
}
}
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
// This is the key fix: pushes content up when keyboard appears
.imePadding(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
"Enter the value of cards left in each player's hand.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
}
items(entries, key = { it.gamePlayerId }) { entry ->
RoundEntryCard(
entry = entry,
isLast = entry == entries.last(),
onScoreChange = { vm.updateScore(entry.gamePlayerId, it) },
onTogglePhase = { vm.togglePhaseCompleted(entry.gamePlayerId) },
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { focusManager.clearFocus() }
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
@Composable
private fun RoundEntryCard(
entry: RoundEntry,
isLast: Boolean,
onScoreChange: (String) -> Unit,
onTogglePhase: () -> Unit,
onNext: () -> Unit,
onDone: () -> Unit
) {
val phaseRule = remember(entry.currentPhase) { getPhaseRule(entry.currentPhase) }
val scoreInt = entry.scoreInput.trim().toIntOrNull()
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
entry.playerName.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(entry.playerName, style = MaterialTheme.typography.titleMedium)
Text(
"Phase ${entry.currentPhase}: ${phaseRule.title}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(14.dp))
// Score field — full width now
OutlinedTextField(
value = entry.scoreInput,
onValueChange = { input ->
if (input.all { it.isDigit() }) onScoreChange(input)
},
label = { Text("Score") },
placeholder = { Text("0") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = if (isLast) ImeAction.Done else ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { onNext() },
onDone = { onDone() }
),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
supportingText = {
when {
scoreInt == 0 ->
Text("🏆 Went out!", color = MaterialTheme.colorScheme.primary)
entry.autoCompleted ->
Text("✓ Phase auto-completed", color = MaterialTheme.colorScheme.tertiary)
else -> {}
}
},
isError = entry.scoreInput.isNotEmpty() && scoreInt == null
)
// Phase Done — full-width tappable row below the score field
Surface(
onClick = { if (!entry.autoCompleted) onTogglePhase() },
shape = MaterialTheme.shapes.large,
color = if (entry.phaseCompleted)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = entry.phaseCompleted,
onCheckedChange = {
if (!entry.autoCompleted) onTogglePhase()
},
enabled = !entry.autoCompleted
)
Spacer(Modifier.width(4.dp))
Text(
text = when {
scoreInt == 0 -> "Phase completed ✓"
entry.autoCompleted -> "Phase completed (auto)"
entry.phaseCompleted -> "Phase completed"
else -> "Phase not completed"
},
style = MaterialTheme.typography.bodyMedium,
color = if (entry.phaseCompleted)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun CardValuesDialog(onDismiss: () -> Unit) {
val cardValues = listOf(
Triple("1 9", "Single digit cards", "5 pts each"),
Triple("10, 11, 12","Double digit cards", "10 pts each"),
Triple("Skip", "Skip card", "15 pts each"),
Triple("Wild", "Wild card", "25 pts each"),
)
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, contentDescription = null) },
title = { Text("Card Point Values") },
text = {
Column {
Text(
"Points are based on the cards left in your hand at the end of each round.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
cardValues.forEachIndexed { index, (cards, label, points) ->
if (index > 0) HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.width(72.dp)
) {
Text(
text = cards,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
Spacer(Modifier.width(12.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Text(
text = points,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Got it") }
}
)
}
@@ -0,0 +1,85 @@
package com.crsmthw.phase10tracker.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// ── 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),
secondary = Color(0xFFFFBB33), // warm amber
onSecondary = Color(0xFF3D2800),
secondaryContainer = Color(0xFF593D00),
onSecondaryContainer = Color(0xFFFFDFA0),
tertiary = Color(0xFF5CE0C0), // teal
onTertiary = Color(0xFF003730),
background = Color(0xFF0E0E18),
onBackground = Color(0xFFE8E5FF),
surface = Color(0xFF141425),
onSurface = Color(0xFFE8E5FF),
surfaceVariant = Color(0xFF1F1F38),
onSurfaceVariant = Color(0xFFBBB8D4),
error = Color(0xFFFF6B6B),
onError = Color(0xFF690005),
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF3A2FBF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD8D0FF),
onPrimaryContainer = Color(0xFF1A0070),
secondary = Color(0xFF8A5E00),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFDFA0),
onSecondaryContainer = Color(0xFF3D2800),
tertiary = Color(0xFF006B5E),
onTertiary = Color(0xFFFFFFFF),
background = Color(0xFFF5F3FF),
onBackground = Color(0xFF1A1830),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1830),
surfaceVariant = Color(0xFFEAE6FF),
onSurfaceVariant = Color(0xFF4A4670),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
)
@Composable
fun Phase10Theme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Phase10Typography,
shapes = Phase10Shapes,
content = content
)
}
@@ -0,0 +1,109 @@
package com.crsmthw.phase10tracker.ui.theme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.RoundedCornerShape
// Using system default sans-serif (Roboto Flex on Android 12+)
// which gets the M3 Expressive treatment automatically.
// For a more distinctive look we apply custom sizing/weight choices.
val Phase10Typography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Black,
fontSize = 56.sp,
lineHeight = 64.sp,
letterSpacing = (-1).sp
),
displayMedium = TextStyle(
fontWeight = FontWeight.ExtraBold,
fontSize = 44.sp,
lineHeight = 52.sp,
letterSpacing = (-0.5).sp
),
displaySmall = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp
),
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
// M3 Expressive encourages more rounded, expressive shapes
val Phase10Shapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp)
)
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#3A2FBF" />
</shape>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Foreground icon — artwork by friend, adapted for Android adaptive icon.
Expanded viewport (543x543) centers and shrinks artwork to ~66dp
within the 108dp canvas, safely inside the 72dp safe zone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="618.2"
android:viewportHeight="618.2">
<!-- Translate to re-center artwork within expanded viewport -->
<group android:translateX="88.1" android:translateY="89.6">
<path
android:fillColor="#FFFFFF"
android:pathData="M201.675 71C207.819 71.0316 213.812 72.9122 218.868 76.3965C223.923 79.8807 227.808 84.8065 230.014 90.5293L308.296 277.229C309.705 281.29 310.488 285.821 310.331 289.883C310.018 301.444 302.816 313.005 291.074 317.849L175.686 365.5C171.615 367.219 167.387 368 163.317 368C157.185 367.832 151.235 365.879 146.201 362.382C141.166 358.884 137.265 353.995 134.979 348.314L57.3226 161.615C54.1898 153.97 54.2279 145.396 57.4281 137.779C60.6282 130.162 66.7285 124.125 74.388 120.995L189.619 73.3438C193.534 71.9377 197.605 71 201.675 71ZM139.08 198.93L126.429 210.438L130.519 220.814L142.99 208.848L164.338 263.012L176.698 258.141L151.439 194.059L139.08 198.93ZM206.781 177.27C203.299 175.293 198.684 175.437 192.937 177.702L178.747 183.295C174.933 184.798 172.116 186.673 170.298 188.917C168.511 191.09 167.584 193.659 167.518 196.623C167.503 199.567 168.227 202.896 169.691 206.608L181.478 236.513C183.663 242.056 186.532 245.832 190.085 247.84C193.618 249.796 198.258 249.642 204.005 247.377L218.195 241.784C223.941 239.519 227.414 236.476 228.611 232.655C229.838 228.763 229.359 224.045 227.174 218.502L215.387 188.597C213.182 183.003 210.313 179.226 206.781 177.27ZM193.448 188.166C195.024 187.545 196.375 187.394 197.5 187.715C198.676 188.015 199.643 188.604 200.4 189.48C201.207 190.337 201.863 191.255 202.366 192.231C202.868 193.208 203.269 194.078 203.57 194.841L214.351 221.97C214.631 222.682 214.932 223.592 215.251 224.7C215.55 225.757 215.697 226.875 215.691 228.052C215.736 229.209 215.43 230.299 214.776 231.321C214.172 232.323 213.056 233.145 211.429 233.786L204.029 236.703C201.384 237.745 199.217 237.689 197.528 236.533C195.889 235.358 194.518 233.371 193.415 230.574L182.395 202.835C181.272 199.987 180.919 197.599 181.334 195.673C181.781 193.675 183.326 192.156 185.971 191.113L193.448 188.166ZM362.623 103.809C378.749 110.37 386.109 128.65 379.69 144.586L341.644 236.139V95.0596L362.623 103.809ZM278.862 71C287.166 71 295.131 74.2924 301.003 80.1523C306.692 85.8291 309.966 93.4649 310.165 101.471L310.175 201.299L256.16 71H278.862Z"/>
</group>
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="618.2"
android:viewportHeight="618.2">
<group android:translateX="88.1" android:translateY="89.6">
<path
android:fillColor="#000000"
android:pathData="M201.675 71C207.819 71.0316 213.812 72.9122 218.868 76.3965C223.923 79.8807 227.808 84.8065 230.014 90.5293L308.296 277.229C309.705 281.29 310.488 285.821 310.331 289.883C310.018 301.444 302.816 313.005 291.074 317.849L175.686 365.5C171.615 367.219 167.387 368 163.317 368C157.185 367.832 151.235 365.879 146.201 362.382C141.166 358.884 137.265 353.995 134.979 348.314L57.3226 161.615C54.1898 153.97 54.2279 145.396 57.4281 137.779C60.6282 130.162 66.7285 124.125 74.388 120.995L189.619 73.3438C193.534 71.9377 197.605 71 201.675 71ZM139.08 198.93L126.429 210.438L130.519 220.814L142.99 208.848L164.338 263.012L176.698 258.141L151.439 194.059L139.08 198.93ZM206.781 177.27C203.299 175.293 198.684 175.437 192.937 177.702L178.747 183.295C174.933 184.798 172.116 186.673 170.298 188.917C168.511 191.09 167.584 193.659 167.518 196.623C167.503 199.567 168.227 202.896 169.691 206.608L181.478 236.513C183.663 242.056 186.532 245.832 190.085 247.84C193.618 249.796 198.258 249.642 204.005 247.377L218.195 241.784C223.941 239.519 227.414 236.476 228.611 232.655C229.838 228.763 229.359 224.045 227.174 218.502L215.387 188.597C213.182 183.003 210.313 179.226 206.781 177.27ZM193.448 188.166C195.024 187.545 196.375 187.394 197.5 187.715C198.676 188.015 199.643 188.604 200.4 189.48C201.207 190.337 201.863 191.255 202.366 192.231C202.868 193.208 203.269 194.078 203.57 194.841L214.351 221.97C214.631 222.682 214.932 223.592 215.251 224.7C215.55 225.757 215.697 226.875 215.691 228.052C215.736 229.209 215.43 230.299 214.776 231.321C214.172 232.323 213.056 233.145 211.429 233.786L204.029 236.703C201.384 237.745 199.217 237.689 197.528 236.533C195.889 235.358 194.518 233.371 193.415 230.574L182.395 202.835C181.272 199.987 180.919 197.599 181.334 195.673C181.781 193.675 183.326 192.156 185.971 191.113L193.448 188.166ZM362.623 103.809C378.749 110.37 386.109 128.65 379.69 144.586L341.644 236.139V95.0596L362.623 103.809ZM278.862 71C287.166 71 295.131 74.2924 301.003 80.1523C306.692 85.8291 309.966 93.4649 310.165 101.471L310.175 201.299L256.16 71H278.862Z"/>
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base M3 theme; Compose handles all actual colors/typography -->
<style name="Theme.Phase10Tracker" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>