Initial commit
@@ -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>
|
||||
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 414 B |
|
After Width: | Height: | Size: 414 B |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 546 B |
@@ -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>
|
||||