16 Commits

Author SHA1 Message Date
crsmthw f70e082104 v3.0.1 - Minor bug fixes 2026-05-17 10:10:53 +03:00
crsmthw cbcc2c0dbc Stop tracking build artifacts 2026-05-17 09:36:02 +03:00
crsmthw 383da18391 v3.0.0 - Preset Phases and Better Dark Mode 2026-05-17 09:30:40 +03:00
crsmthw 8eb3ea5c88 added screenshots 2026-05-15 16:16:54 +03:00
crsmthw cb458429e4 fixed version 2026-05-15 15:25:03 +03:00
crsmthw 6eb41643be Updated Readme 2026-05-15 15:12:19 +03:00
crsmthw 6bcc8c2f05 Updated Readme 2026-05-15 15:05:48 +03:00
crsmthw c0e3d8af39 v2 - Home Screen Streamlined 2026-05-15 14:34:41 +03:00
crsmthw be70a3d3de Remove .gradle from tracking 2026-05-15 11:43:51 +03:00
crsmthw fa4e651ada Add .gradle to gitignore 2026-05-15 11:43:22 +03:00
crsmthw 8be13ed8db Ignore keystore files 2026-05-15 11:07:16 +03:00
crsmthw f9f3fc42e3 Remove .idea from tracking 2026-05-15 10:35:40 +03:00
crsmthw 5b082591a2 Remove local.properties from tracking 2026-05-15 10:35:17 +03:00
crsmthw 115e4a32ac Remove build artifacts and update .gitignore 2026-05-15 10:33:46 +03:00
crsmthw 6f8c426520 Initial commit 2026-05-15 10:19:38 +03:00
crsmthw 04b11797d0 Initial commit 2026-05-15 10:03:12 +03:00
63 changed files with 5332 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# ---> Android
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
build/
local.properties
.idea/
*.jks
*.keystore
.gradle/
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 crsmthw
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+149
View File
@@ -0,0 +1,149 @@
# Phase 10 Score Tracker
An ad-free, open source score tracker for the Phase 10 card game. Built for Android with Jetpack Compose and Material 3 Expressive.
---
## Why This Exists
Every Phase 10 score tracker app on the Play Store falls into one of a few categories: riddled with ads that break on an AdGuard network, so basic they're just a notepad with a counter, or missing obvious features like saving your regular players so you don't have to type the same six names every single game. This one does none of that. It works fully offline, has no ads, no tracking, no analytics, and no nonsense — and it actually remembers who you play with.
---
## Screenshots
<p float="left">
<img src="assets/screenshots/home.png" width="200" />
<img src="assets/screenshots/setup.png" width="200" />
<img src="assets/screenshots/game.png" width="200" />
<img src="assets/screenshots/game-detail.png" width="200" />
</p>
<p float="left">
<img src="assets/screenshots/entry.png" width="200" />
<img src="assets/screenshots/result.png" width="200" />
<img src="assets/screenshots/leaderboard.png" width="200" />
</p>
### Foldable / Tablet — Dual Pane
<img src="assets/screenshots/game-unfolded.png" width="600" />
## Features
### Game Management
- **Saved player roster** — add your regular crew once, pick them from the list every game
- **Flexible game setup** — select any combination of saved players, drag to reorder them before the game starts
- **Dealer rotation** — automatically tracks who the dealer is each round, based on the player order set at game start
- **Resume game** — if the app is killed mid-game (RAM cleared, crash, whatever), your game is saved and waiting when you reopen it
- **End game early** — stop the game at any point; the current leader is declared the winner based on highest phase reached, then lowest score as tiebreaker
- **Tied winner support** — if two players finish on the same phase with the same score, both are declared winners and both get the win recorded
### Scoring
- **Cumulative scoring** — enter card values left in each player's hand at the end of every round; the app adds them up
- **Phase tracking** — each player's current phase is tracked automatically; it advances when they complete a phase
- **Smart phase completion** — if a player's score is 0 (went out) or below 50 (completed phase, few cards left), the "Phase Completed" toggle is checked automatically
- **Manual override** — the phase completion toggle can be manually checked for edge cases (e.g. a player completes their phase but foolishly holds wild cards, pushing their score above 50)
- **Card values reference** — tap the ️ button on the round entry screen for a quick reminder of how much each card type is worth (single digits: 5pts, double digits: 10pts, Skip: 15pts, Wild: 25pts)
- **Correct winner logic** — highest phase reached wins; lowest score breaks ties among players on the same phase
### Screens
**Home** — start a new game, resume an in-progress game, or browse the leaderboard
**Game Setup** — pick players from your saved roster, set the phase ruleset, drag cards into play order using the ≡ handle with haptic feedback
**Active Game (Scores tab)** — live scoreboard sorted by highest phase then lowest score. Rank badges for all players. Tap any card to expand the current phase rule. Dealer badge shown inline
**Active Game (By Phase tab)** — players grouped by their current phase, with the phase rule shown as a header above each group
**Round Entry** — full-width score input per player, keyboard-aware layout so fields are never hidden. Phase completion shown as a tappable row below each score field
**Game Results** — winner announcement with animated trophy, tie support, full final standings sorted by phase then score
**Leaderboard** — lifetime stats for every saved player: games played, wins, win percentage. Sorted by win %
**Custom Rules** — create named rule sets with custom phase descriptions. Select them at game setup instead of the official rules. Official rules shown as a reference card
### Adaptive Layout
- **Foldable support** — on the Samsung Galaxy Z Fold 6 (and any wide-screen device), the Active Game screen shows Scores and By Phase side by side simultaneously
- **Seamless transition** — folding and unfolding the phone transitions between single and dual pane layouts automatically
- **Tablet ready** — same dual-pane layout activates on tablets at ≥600dp width
### Design
- **Material 3 Expressive** — built on the latest Material You design system
- **Dynamic color** — app colors are extracted from your wallpaper automatically on Android 12+
- **Themed icon** — monochrome adaptive icon layer means the app icon adopts your wallpaper palette in themed icon mode
- **Dark mode** — full dark theme support, follows system setting
- **Edge to edge** — content renders behind the status and navigation bars properly
---
## Tech Stack
| Layer | Library |
|---|---|
| UI | Jetpack Compose |
| Design system | Material 3 (`material3:1.5.0-alpha19`) |
| Navigation | Compose Navigation |
| Database | Room |
| Reactive state | Kotlin Flow + StateFlow |
| Architecture | MVVM (ViewModel + Repository) |
| Drag reorder | `sh.calvin.reorderable` |
| Adaptive layout | `androidx.compose.material3.adaptive` |
| Build | AGP 9.2.1, Kotlin 2.3.10, KSP 2.3.8 |
| Min SDK | 35 (Android 15) |
| Target SDK | 37 (Android 16) |
---
## Install
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/CrsMthw/Phase10-Tracker)
Tapping this button on your Android device will open Obtainium and automatically add the repo — it'll notify you and install new releases automatically from then on.
Or go to the Releases page and download the latest APK manually.
---
## Building
Requirements: Android Studio (latest stable), JDK 17+, Android SDK 37.
```bash
git clone https://gitea.crsmthw.com/cris/phase10tracker.git
cd phase10tracker
# Open in Android Studio and let Gradle sync
# Or build from terminal:
./gradlew assembleDebug
```
The APK will be at `app/build/outputs/apk/debug/app-debug.apk`.
---
## Official Phase 10 Rules (reference)
| Phase | Rule |
|---|---|
| 1 | 2 sets of 3 |
| 2 | 1 set of 3 + 1 run of 4 |
| 3 | 1 set of 4 + 1 run of 4 |
| 4 | 1 run of 7 |
| 5 | 1 run of 8 |
| 6 | 1 run of 9 |
| 7 | 2 sets of 4 |
| 8 | 7 cards of 1 color |
| 9 | 1 set of 5 + 1 set of 2 |
| 10 | 1 set of 5 + 1 set of 3 |
---
## License
MIT. Do whatever you want with it.
---
*Built with Claude — because the Play Store didn't deserve another ad-infested score tracker.*
+109
View File
@@ -0,0 +1,109 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
}
android {
namespace = "com.crsmthw.phase10tracker"
compileSdk = 37
defaultConfig {
applicationId = "com.crsmthw.phase10tracker"
minSdk = 35
targetSdk = 37
versionCode = 5
versionName = "3.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM — pins all stable Compose versions
val composeBom = platform("androidx.compose:compose-bom:2026.04.01")
implementation(composeBom)
androidTestImplementation(composeBom)
// Core Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Material 3 Expressive — alpha for full M3 Expressive API surface
implementation("androidx.compose.material3:material3:1.5.0-alpha19")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
// Adaptive layouts (for foldable / tablet)
implementation("androidx.compose.material3.adaptive:adaptive:1.2.0")
implementation("androidx.compose.material3.adaptive:adaptive-layout:1.2.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.2.0")
// Activity & Window
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.window:window:1.5.1")
// Lifecycle / ViewModel
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
// Navigation
implementation("androidx.navigation:navigation-compose:2.9.8")
// Room (local DB for persistent state)
implementation("androidx.room:room-runtime:2.8.4")
implementation("androidx.room:room-ktx:2.8.4")
ksp("androidx.room:room-compiler:2.8.4")
// DataStore (for simple prefs)
implementation("androidx.datastore:datastore-preferences:1.2.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
// Drag-to-reorder for LazyColumn
implementation("sh.calvin.reorderable:reorderable:3.1.0")
// Test
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
+2
View File
@@ -0,0 +1,2 @@
-keep class com.crsmthw.phase10tracker.data.** { *; }
-keepattributes *Annotation*
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.crsmthw.phase10tracker",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 5,
"versionName": "3.0.1",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 35
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Phase 10 Tracker"
android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true"
android:theme="@style/Theme.Phase10Tracker"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,51 @@
package com.crsmthw.phase10tracker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import com.crsmthw.phase10tracker.data.ThemePreferenceManager
import com.crsmthw.phase10tracker.ui.Phase10NavHost
import com.crsmthw.phase10tracker.ui.ThemeViewModel
import com.crsmthw.phase10tracker.ui.ThemeViewModelFactory
import com.crsmthw.phase10tracker.ui.theme.Phase10Theme
class MainActivity : ComponentActivity() {
private val themeVm: ThemeViewModel by viewModels {
ThemeViewModelFactory(ThemePreferenceManager(applicationContext))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val themeMode by themeVm.themeMode.collectAsState()
val amoledBlack by themeVm.amoledBlack.collectAsState()
Phase10Theme(
themeMode = themeMode,
amoledBlack = amoledBlack,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
Phase10NavHost(
navController = navController,
themeVm = themeVm
)
}
}
}
}
}
@@ -0,0 +1,55 @@
package com.crsmthw.phase10tracker.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
// ── Theme Mode ─────────────────────────────────────────────────────────────────
enum class ThemeMode { SYSTEM, LIGHT, DARK }
// One DataStore instance per process — scoped to the application context
private val Context.themeDataStore: DataStore<Preferences> by preferencesDataStore(
name = "theme_prefs"
)
// ── Preference Manager ─────────────────────────────────────────────────────────
class ThemePreferenceManager(private val context: Context) {
companion object {
private val KEY_THEME_MODE = intPreferencesKey("theme_mode")
private val KEY_AMOLED = booleanPreferencesKey("amoled_black")
}
/** Emits the saved ThemeMode; defaults to SYSTEM on first run. */
val themeMode: Flow<ThemeMode> = context.themeDataStore.data.map { prefs ->
when (prefs[KEY_THEME_MODE] ?: 0) {
1 -> ThemeMode.LIGHT
2 -> ThemeMode.DARK
else -> ThemeMode.SYSTEM
}
}
/** Emits whether AMOLED Pure Black mode is enabled; defaults to false. */
val amoledBlack: Flow<Boolean> = context.themeDataStore.data.map { prefs ->
prefs[KEY_AMOLED] ?: false
}
suspend fun setThemeMode(mode: ThemeMode) {
context.themeDataStore.edit { prefs ->
prefs[KEY_THEME_MODE] = when (mode) {
ThemeMode.LIGHT -> 1
ThemeMode.DARK -> 2
ThemeMode.SYSTEM -> 0
}
}
}
suspend fun setAmoledBlack(enabled: Boolean) {
context.themeDataStore.edit { prefs ->
prefs[KEY_AMOLED] = enabled
}
}
}
@@ -0,0 +1,121 @@
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?
}
// ── CustomPhaseSet DAO ───────────────────────────────────────────────────────
@Dao
interface CustomPhaseSetDao {
@Query("SELECT * FROM custom_phase_sets ORDER BY createdAt DESC")
fun getAllPhaseSets(): Flow<List<CustomPhaseSetEntity>>
@Query("SELECT * FROM custom_phase_sets WHERE id = :id")
suspend fun getPhaseSetById(id: Long): CustomPhaseSetEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPhaseSet(phaseSet: CustomPhaseSetEntity): Long
@Delete
suspend fun deletePhaseSet(phaseSet: CustomPhaseSetEntity)
}
@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,61 @@
package com.crsmthw.phase10tracker.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.crsmthw.phase10tracker.data.model.*
// ── Migration 2 → 3 ───────────────────────────────────────────────────────────
// • Adds phaseSetId column to games table
// • Renames custom_rule_sets table to custom_phase_sets
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// Store which phase set was used when the game was created
db.execSQL("ALTER TABLE games ADD COLUMN phaseSetId INTEGER NOT NULL DEFAULT -1")
// Rename table to match the updated entity name
db.execSQL("ALTER TABLE custom_rule_sets RENAME TO custom_phase_sets")
}
}
@Database(
entities = [
PlayerEntity::class,
GameEntity::class,
GamePlayerEntity::class,
RoundEntity::class,
CustomPhaseSetEntity::class
],
version = 3,
exportSchema = false
)
abstract class Phase10Database : RoomDatabase() {
abstract fun playerDao(): PlayerDao
abstract fun gameDao(): GameDao
abstract fun gamePlayerDao(): GamePlayerDao
abstract fun roundDao(): RoundDao
abstract fun customPhaseSetDao(): CustomPhaseSetDao
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"
)
.addMigrations(MIGRATION_2_3)
.fallbackToDestructiveMigration(true)
.build()
.also { INSTANCE = it }
}
}
}
}
@@ -0,0 +1,101 @@
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
// -1 = Official Phases, -2..-15 = preset index, positive = custom phase set DB id
val phaseSetId: Long = -1L
)
// ── 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 phase sets ────────────────────────────────────────────────────────
@Entity(tableName = "custom_phase_sets")
data class CustomPhaseSetEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val rulesJson: String, // JSON array of PhaseRule serialized
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,221 @@
package com.crsmthw.phase10tracker.data.model
data class PhaseRule(
val phaseNumber: Int,
val title: String,
val description: String
)
// ── Preset phase set container ─────────────────────────────────────────────────
data class PresetPhaseSet(
val name: String,
val phases: List<PhaseRule>
)
/** Converts a list of title strings into numbered PhaseRules (description = title). */
private fun phaseList(vararg titles: String): List<PhaseRule> =
titles.mapIndexed { i, t -> PhaseRule(i + 1, t, t) }
val PRESET_PHASE_SETS: List<PresetPhaseSet> = listOf(
PresetPhaseSet("Island Paradise", phaseList(
"1 run of 7",
"1 set of 2 + 2 sets of 3",
"1 run of 6 + 1 set of 2",
"3 sets of 2 + 1 set of 3",
"1 set of 3 + 1 run of 6",
"2 runs of 4",
"3 cards of one color + 1 set of 4",
"8 cards of one color",
"4 cards of one color + 1 set of 5",
"9 cards of one color"
)),
PresetPhaseSet("Cocoa Canyon", phaseList(
"6 cards of one color",
"2 sets of 3",
"1 run of 8",
"1 set of 3 + 1 run of 5",
"7 cards of even or odd",
"1 run of 4",
"1 set of 5 + 1 run of 4",
"6 cards of even or odd",
"1 set of 4 + 1 run of 3",
"1 set of 5 + 1 run of 5"
)),
PresetPhaseSet("Disco Fever", phaseList(
"8 even or odd cards",
"9 even or odd cards",
"1 color run of 3 + 2 sets of 2",
"7 of one color",
"1 color run of 5 + 2 sets of 2",
"Same color even or odd of 3 + same color even or odd of 4",
"1 color run of 4 + 1 set of 4",
"1 color run of 4 + 3 sets of 2",
"1 run of 3 + 2 sets of 3",
"1 run of 3 + 1 set of 4 + 1 set of 3"
)),
PresetPhaseSet("Cupcake Lounge", phaseList(
"3 of one color + 3 of one color + 4 of one color",
"1 color run of 3 + 2 sets of 2",
"1 set of 4 + 1 wild",
"2 sets of 3",
"1 run of 7",
"1 set of 4",
"2 color even or odd of 4",
"1 run of 9",
"1 color run of 5 + 2 sets of 2",
"1 color run of 6 + 1 set of 2"
)),
PresetPhaseSet("Mountain Vista", phaseList(
"1 run of 3 + 3 sets of 2",
"1 run of 8",
"1 run of 9",
"1 color run of 3 + 1 set of 3",
"1 set of 2 + 2 sets of 3",
"1 set of 2 + 1 set of 3 + 1 set of 4",
"4 of one color + 6 of one color",
"5 of one color + 5 of one color",
"1 run of 5 + 1 set of 3 + 1 set of 2",
"1 run of 3 + 1 set of 4 + 1 set of 3"
)),
PresetPhaseSet("Prehistoric Valley", phaseList(
"1 even or odd of 9",
"1 even or odd of 10",
"1 run of 8",
"1 run of 10",
"2 sets of 3",
"2 sets of 4",
"1 color run of 4",
"1 color run of 3 + 3 of one color",
"1 set of 3 + 1 run of 4",
"1 set of 4 + 1 run of 6"
)),
PresetPhaseSet("Moonlight Drive-In", phaseList(
"1 set of 4 + 2 sets of 2",
"2 sets of 3 + 3 of one color",
"1 run of 7",
"1 run of 8",
"1 set of 2 + 2 sets of 3",
"1 set of 5",
"1 run of 9",
"1 run of 6 + 2 sets of 2",
"1 run of 8 + 1 set of 2",
"1 set of 4 + 1 run of 6"
)),
PresetPhaseSet("Ancient Greece", phaseList(
"1 set of 2 + 1 run of 6",
"1 even or odd of 9",
"1 even or odd of 10",
"1 color run of 3 + 1 set of 3",
"1 set of 3 + 1 run of 5",
"1 set of 5 + 1 run of 4",
"1 color run of 5",
"1 color even or odd of 3 + 1 color even or odd of 5",
"5 sets of 2",
"2 sets of 3 + 2 sets of 2"
)),
PresetPhaseSet("Jazz Club", phaseList(
"1 even or odd of 5",
"1 color run of 3 + 1 set of 3",
"1 set of 2 + 1 run of 4",
"1 color run of 4",
"1 run of 4",
"1 set of 4 + 1 run of 3",
"1 color even or odd of 5",
"1 color run of 5 + 1 set of 2",
"1 color even or odd of 6",
"1 color run of 5 + 3 of one color"
)),
PresetPhaseSet("Vintage Gas Station", phaseList(
"1 set of 3 + 1 run of 5",
"1 run of 4 + 1 set of 3 + 1 set of 2",
"1 run of 3 + 1 set of 3 + 2 sets of 2",
"1 color run of 4",
"1 color run of 4 + 1 set of 2",
"1 color run of 4 + 2 sets of 2",
"1 set of 5 + 1 run of 4",
"1 color even or odd of 5",
"1 color even or odd of 6",
"1 color run of 3 + 3 of one color + 1 set of 2"
)),
PresetPhaseSet("Ocean Reef", phaseList(
"1 run of 7",
"1 set of 4 + 1 set of 3",
"1 color run of 5 + 1 set of 2",
"1 even or odd of 10",
"2 sets of 5",
"3 sets of 2",
"1 color run of 3 + 1 set of 3",
"1 color even or odd of 3 + 1 color even or odd of 4",
"1 run of 7 + 1 set of 2",
"1 run of 6"
)),
PresetPhaseSet("Candy Castle", phaseList(
"3 sets of 2",
"1 run of 5 + 1 set of 2",
"1 set of 3 + 1 run of 4",
"Even or odd of 7",
"1 run of 3 + 1 set of 2",
"1 run of 7",
"1 set of 3 + 1 run of 5",
"1 run of 8",
"2 sets of 4",
"2 runs of 3"
)),
PresetPhaseSet("The Empire Strikes Back", phaseList(
"3 sets of 3",
"1 run of 7 + 1 set of 2",
"4 even/odd of same color + 2 sets of 2",
"1 set of 4 + 1 set of 3 + 1 set of 2",
"2 sets of 5",
"1 run of 9",
"1 color run of 5 + 1 set of 3",
"1 color run of 4 + 1 run of 4",
"5 even/odd of same color + 1 set of 4",
"3 sets of 3 + a skip card"
)),
PresetPhaseSet("Sets Gone Wild", phaseList(
"1 set of 4 + 1 run of 5",
"2 sets of 4",
"3 sets of 3 + a wild card",
"1 set of 4 + 1 color run of 6",
"2 sets of 5",
"1 set of 5 + 1 color run of 5",
"1 set of 6 + 1 color run of 4",
"1 set of 4 + 1 color run of 3 + even or odd of 3",
"2 sets of 4 + 1 set of 2",
"1 color run of 10"
)),
)
val OFFICIAL_PHASE_RULES = listOf(
PhaseRule(1, "2 sets of 3", "Two groups of 3 cards with the same number."),
PhaseRule(2, "1 set of 3 + 1 run of 4", "One group of 3 same-number cards, plus 4 consecutive numbers."),
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 phase set UI model
data class CustomPhaseSet(
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,265 @@
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 customPhaseSetDao: CustomPhaseSetDao
) {
// ── 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>,
phaseSetId: Long = -1L
): Long {
// Wipe any lingering incomplete games before starting fresh
gameDao.cancelAllIncompleteGames()
val gameId = gameDao.insertGame(GameEntity(phaseSetId = phaseSetId))
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
}
// ── Phase Rules Resolution ───────────────────────────────────────────────
// Resolves the correct List<PhaseRule> from the ID stored on the game:
// -1 → Official Phases
// -2..-15 → Preset at index = abs(id) - 2
// positive → User-created custom phase set from DB
suspend fun resolvePhaseRules(phaseSetId: Long): List<PhaseRule> = when {
phaseSetId == -1L -> OFFICIAL_PHASE_RULES
phaseSetId < -1L -> {
val presetIndex = (-phaseSetId - 2).toInt()
PRESET_PHASE_SETS.getOrNull(presetIndex)?.phases ?: OFFICIAL_PHASE_RULES
}
else -> {
customPhaseSetDao.getPhaseSetById(phaseSetId)
?.let { parseRulesJson(it.rulesJson) }
?: OFFICIAL_PHASE_RULES
}
}
// ── Live Game State ──────────────────────────────────────────────────────
fun getGameById(gameId: Long): Flow<GameEntity?> = gameDao.getGameById(gameId)
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)
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
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)
val playerCount = gamePlayers.size
val nextDealerIndex = (game.currentDealerIndex + 1) % playerCount
gameDao.advanceRound(
id = gameId,
round = game.currentRound + 1,
dealerIndex = nextDealerIndex
)
val updatedPlayers = gamePlayerDao.getGamePlayersList(gameId)
val finishers = updatedPlayers.filter { it.currentPhase > 10 }
if (finishers.isNotEmpty()) {
val winnerScore = finishers.minOf { it.totalScore }
val winners = finishers.filter { it.totalScore == winnerScore }
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 {
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
gameDao.finishGame(gameId, resolvedWinnerIds.first())
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)
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()
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) {
gameDao.finishGame(gameId, winnerId = -1L)
}
// ── Custom Phase Sets ──────────────────────────────────────────────────────
fun getAllCustomPhaseSets(): Flow<List<CustomPhaseSet>> =
customPhaseSetDao.getAllPhaseSets().map { entities ->
entities.map { entity ->
CustomPhaseSet(
id = entity.id,
name = entity.name,
phases = parseRulesJson(entity.rulesJson)
)
}
}
suspend fun saveCustomPhaseSet(name: String, phases: List<PhaseRule>): Long {
val json = buildRulesJson(phases)
return customPhaseSetDao.insertPhaseSet(
CustomPhaseSetEntity(name = name.trim(), rulesJson = json)
)
}
suspend fun deleteCustomPhaseSet(phaseSet: CustomPhaseSet) {
customPhaseSetDao.deletePhaseSet(
CustomPhaseSetEntity(id = phaseSet.id, name = phaseSet.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,165 @@
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"
const val ABOUT = "about"
fun activeGame(id: Long) = "game/$id"
fun roundEntry(id: Long) = "round/$id"
fun gameResults(id: Long) = "results/$id"
}
@Composable
fun Phase10NavHost(
navController: NavHostController,
themeVm: ThemeViewModel
) {
val context = LocalContext.current
val db = remember { Phase10Database.getInstance(context) }
val repo = remember {
GameRepository(
db.playerDao(),
db.gameDao(),
db.gamePlayerDao(),
db.roundDao(),
db.customPhaseSetDao()
)
}
val factory = remember { ViewModelFactory(repo) }
NavHost(navController = navController, startDestination = Routes.HOME) {
composable(Routes.HOME) {
val vm: HomeViewModel = viewModel(factory = factory)
val themeMode by themeVm.themeMode.collectAsState()
val amoledBlack by themeVm.amoledBlack.collectAsState()
HomeScreen(
vm = vm,
themeMode = themeMode,
amoledBlack = amoledBlack,
onThemeModeChange = themeVm::setThemeMode,
onAmoledBlackChange = themeVm::setAmoledBlack,
onContinueGame = { gameId -> navController.navigate(Routes.activeGame(gameId)) },
onStartNew = { navController.navigate(Routes.GAME_SETUP) },
onLeaderboard = { navController.navigate(Routes.LEADERBOARD) },
onManagePlayers = { navController.navigate(Routes.PLAYER_ROSTER) },
onCustomRules = { navController.navigate(Routes.CUSTOM_RULES) },
onAbout = { navController.navigate(Routes.ABOUT) }
)
}
composable(Routes.PLAYER_ROSTER) {
val vm: PlayerRosterViewModel = viewModel(factory = factory)
PlayerRosterScreen(
vm = vm,
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: CustomPhasesViewModel = viewModel(factory = factory)
CustomPhasesScreen(
vm = vm,
onBack = { navController.popBackStack() }
)
}
composable(Routes.ABOUT) {
AboutScreen(onBack = { navController.popBackStack() })
}
}
}
@@ -0,0 +1,332 @@
package com.crsmthw.phase10tracker.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.crsmthw.phase10tracker.data.ThemeMode
import com.crsmthw.phase10tracker.data.ThemePreferenceManager
import com.crsmthw.phase10tracker.data.model.*
import com.crsmthw.phase10tracker.data.repository.GameRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
// ── Home ViewModel ────────────────────────────────────────────────────────────
class HomeViewModel(private val repo: GameRepository) : ViewModel() {
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 customPhaseSets: StateFlow<List<CustomPhaseSet>> = repo.getAllCustomPhaseSets()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
/** Built-in preset sets — negative IDs so they never clash with DB rows. */
val presetPhaseSets: List<CustomPhaseSet> = PRESET_PHASE_SETS.mapIndexed { i, p ->
CustomPhaseSet(id = -(i + 2).toLong(), name = p.name, phases = p.phases)
}
private val _selectedPlayers = MutableStateFlow<List<PlayerEntity>>(emptyList())
val selectedPlayers: StateFlow<List<PlayerEntity>> = _selectedPlayers
private val _selectedPhaseSet = MutableStateFlow<CustomPhaseSet?>(null)
val selectedPhaseSet: StateFlow<CustomPhaseSet?> = _selectedPhaseSet
fun selectPhaseSet(phaseSet: CustomPhaseSet?) {
_selectedPhaseSet.value = phaseSet
}
/** Picks a random phase set from official + all 14 presets + any user-created sets. */
fun selectRandomPhaseSet() {
val all: List<CustomPhaseSet?> = listOf(null) + presetPhaseSets + customPhaseSets.value
_selectedPhaseSet.value = all.random()
}
fun togglePlayer(player: PlayerEntity) {
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)
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
val phaseSetId = _selectedPhaseSet.value?.id ?: -1L
viewModelScope.launch {
val id = repo.startNewGame(players, phaseSetId)
_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)
/** Resolves the correct phase rules for this game from its stored phaseSetId. */
val phaseRules: StateFlow<List<PhaseRule>> = gameState
.map { game -> repo.resolvePhaseRules(game?.phaseSetId ?: -1L) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OFFICIAL_PHASE_RULES)
private val _gameFinished = MutableStateFlow(false)
val gameFinished: StateFlow<Boolean> = _gameFinished
private val _gameCancelled = MutableStateFlow(false)
val gameCancelled: StateFlow<Boolean> = _gameCancelled
init {
viewModelScope.launch {
gameState.collect { game ->
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) {
_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)
/** Resolves the correct phase rules for this game from its stored phaseSetId. */
val phaseRules: StateFlow<List<PhaseRule>> = gameState
.map { game -> repo.resolvePhaseRules(game?.phaseSetId ?: -1L) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OFFICIAL_PHASE_RULES)
init {
viewModelScope.launch {
repo.getGamePlayers(gameId).collect { gamePlayers ->
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()
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
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 Phases ViewModel ───────────────────────────────────────────────────
class CustomPhasesViewModel(private val repo: GameRepository) : ViewModel() {
val customPhaseSets: StateFlow<List<CustomPhaseSet>> = repo.getAllCustomPhaseSets()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun savePhaseSet(name: String, phases: List<PhaseRule>) {
if (name.isBlank() || phases.isEmpty()) return
viewModelScope.launch { repo.saveCustomPhaseSet(name, phases) }
}
fun deletePhaseSet(phaseSet: CustomPhaseSet) {
viewModelScope.launch { repo.deleteCustomPhaseSet(phaseSet) }
}
}
// ── Theme ViewModel ───────────────────────────────────────────────────────────
class ThemeViewModel(private val themePrefs: ThemePreferenceManager) : ViewModel() {
val themeMode: StateFlow<ThemeMode> = themePrefs.themeMode
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM)
val amoledBlack: StateFlow<Boolean> = themePrefs.amoledBlack
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { themePrefs.setThemeMode(mode) }
}
fun setAmoledBlack(enabled: Boolean) {
viewModelScope.launch { themePrefs.setAmoledBlack(enabled) }
}
}
class ThemeViewModelFactory(
private val themePrefs: ThemePreferenceManager
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ThemeViewModel::class.java)) {
return ThemeViewModel(themePrefs) as T
}
throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
}
// ── 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(CustomPhasesViewModel::class.java) -> CustomPhasesViewModel(repo) as T
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
}
@@ -0,0 +1,205 @@
package com.crsmthw.phase10tracker.ui.screens
import android.content.Intent
import androidx.core.net.toUri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
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.clip
import androidx.compose.ui.platform.LocalContext
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit) {
val context = LocalContext.current
fun openUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("About") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// App icon + name
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(100.dp)
.clip(MaterialTheme.shapes.extraLarge)
.background(MaterialTheme.colorScheme.primary)
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Phase 10 Tracker",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
"Ad-free. Open source. Always.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
// Credits
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Credits",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("❤️", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(8.dp))
Column {
Text(
"Made by CrsMthw and Claude",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
"App design & development",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("❤️", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(8.dp))
Column {
Text(
"Icon by Shubbu",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
"App icon design",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// GitHub
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { openUrl("https://github.com/CrsMthw") },
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.Code,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
"GitHub",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"github.com/CrsMthw",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
// Buy Me a Coffee button
Image(
painter = painterResource(id = R.drawable.bmc_button),
contentDescription = "Buy me a coffee",
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.clip(MaterialTheme.shapes.extraLarge)
.clickable { openUrl("https://buymeacoffee.com/crsmthw") }
)
HorizontalDivider()
// Bottom tagline
Text(
"Because the people didn't deserve\nanother ad-infested score tracker.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
}
}
}
@@ -0,0 +1,470 @@
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.PhaseRule
import com.crsmthw.phase10tracker.data.model.PlayerGameState
import com.crsmthw.phase10tracker.ui.ActiveGameViewModel
private enum class ViewMode { SCORES, PHASES }
// ── Helper: resolve the right PhaseRule from the game's list ─────────────────
private fun List<PhaseRule>.forPhase(phase: Int): PhaseRule =
getOrElse(phase - 1) { last() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActiveGameScreen(
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()
val phaseRules by vm.phaseRules.collectAsState()
var showEndGameDialog by remember { mutableStateOf(false) }
var viewMode by remember { mutableStateOf(ViewMode.SCORES) }
LaunchedEffect(gameFinished) { if (gameFinished) onGameEnd() }
LaunchedEffect(gameCancelled) { if (gameCancelled) onGameCancelled() }
val adaptiveInfo = currentWindowAdaptiveInfo()
val isTwoPane = adaptiveInfo.windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
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) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
PaneSectionHeader("Scores")
ScoresView(boardState = boardState, phaseRules = phaseRules, fabClearance = false)
}
VerticalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.fillMaxHeight()
)
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
PaneSectionHeader("By Phase")
PhasesView(boardState = boardState, phaseRules = phaseRules, fabClearance = false)
}
}
} else {
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, phaseRules, fabClearance = true)
ViewMode.PHASES -> PhasesView(boardState, phaseRules, 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>,
phaseRules: List<PhaseRule>,
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, phaseRules = phaseRules)
}
if (fabClearance) item { Spacer(Modifier.height(80.dp)) }
}
}
// ── Phases pane ───────────────────────────────────────────────────────────────
@Composable
private fun PhasesView(
boardState: List<PlayerGameState>,
phaseRules: List<PhaseRule>,
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 = phaseRules.forPhase(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,
phaseRules: List<PhaseRule>
) {
var expanded by remember { mutableStateOf(false) }
val phase = minOf(player.currentPhase, 10)
val phaseRule = phaseRules.forPhase(phase)
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,435 @@
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.CustomPhaseSet
import com.crsmthw.phase10tracker.data.model.OFFICIAL_PHASE_RULES
import com.crsmthw.phase10tracker.data.model.PhaseRule
import com.crsmthw.phase10tracker.data.model.PRESET_PHASE_SETS
import com.crsmthw.phase10tracker.data.model.PresetPhaseSet
import com.crsmthw.phase10tracker.ui.CustomPhasesViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomPhasesScreen(
vm: CustomPhasesViewModel,
onBack: () -> Unit
) {
val ruleSets by vm.customPhaseSets.collectAsState()
var showCreateSheet by remember { mutableStateOf(false) }
var ruleSetToDelete by remember { mutableStateOf<CustomPhaseSet?>(null) }
ruleSetToDelete?.let { rs ->
AlertDialog(
onDismissRequest = { ruleSetToDelete = null },
icon = { Icon(Icons.Filled.DeleteOutline, null) },
title = { Text("Delete \"${rs.name}\"?") },
text = { Text("This phase set will be permanently deleted.") },
confirmButton = {
Button(
onClick = { vm.deletePhaseSet(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.savePhaseSet(name, phases)
showCreateSheet = false
},
onDismiss = { showCreateSheet = false }
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Custom Phases") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showCreateSheet = true },
icon = { Icon(Icons.Filled.Add, null) },
text = { Text("New Phase Set") }
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Official rules reference card
item {
Text(
"Official Phases (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)
}
}
}
}
}
// ── Preset Phase Sets ────────────────────────────────────────────
item {
Spacer(Modifier.height(4.dp))
Text(
"Preset Phase Sets",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 4.dp)
)
}
items(PRESET_PHASE_SETS, key = { "preset_${it.name}" }) { preset ->
PresetPhaseSetCard(preset = preset)
}
// ── User Custom Phase Sets ───────────────────────────────────────
if (ruleSets.isNotEmpty()) {
item {
Spacer(Modifier.height(4.dp))
Text(
"Your Custom Phase 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 phase 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: CustomPhaseSet,
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
)
}
}
}
}
}
}
// ── Preset Phase Set card (read-only, no delete) ──────────────────────────────
@Composable
private fun PresetPhaseSetCard(preset: PresetPhaseSet) {
var expanded by remember { mutableStateOf(false) }
Card(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
preset.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onTertiaryContainer
)
Text(
"${preset.phases.size} phases",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
Spacer(Modifier.width(8.dp))
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
}
if (expanded) {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f))
Spacer(Modifier.height(8.dp))
preset.phases.forEachIndexed { index, phase ->
if (index > 0) Spacer(Modifier.height(4.dp))
Row {
Text(
"P${phase.phaseNumber}:",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.width(32.dp)
)
Text(
phase.title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
}
}
}
// ── Create Rule Set bottom sheet ──────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@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 Phase Set",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = ruleSetName,
onValueChange = { ruleSetName = it },
label = { Text("Phase 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 phases:",
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 Phase 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,439 @@
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 customPhaseSets by vm.customPhaseSets.collectAsState()
val selectedPhaseSet by vm.selectedPhaseSet.collectAsState()
val presetPhaseSets = vm.presetPhaseSets
var showAddDialog by remember { mutableStateOf(false) }
var showRuleDropdown by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
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)
) {
// ── Phase set selector ───────────────────────────────────────────
item(key = "rules_header") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Phase",
style = MaterialTheme.typography.titleLarge,
)
// Random phase-set picker
FilledTonalIconButton(
onClick = { vm.selectRandomPhaseSet() },
) {
Icon(
Icons.Filled.Casino,
contentDescription = "Pick random phase set"
)
}
}
Spacer(Modifier.height(4.dp))
ExposedDropdownMenuBox(
expanded = showRuleDropdown,
onExpandedChange = { showRuleDropdown = it }
) {
OutlinedTextField(
value = selectedPhaseSet?.name ?: "Official Phases",
onValueChange = {},
readOnly = true,
label = { Text("Phase set") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showRuleDropdown) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
shape = MaterialTheme.shapes.medium
)
ExposedDropdownMenu(
expanded = showRuleDropdown,
onDismissRequest = { showRuleDropdown = false }
) {
// ── Official ─────────────────────────────────────────
DropdownMenuItem(
text = { Text("Official Phases") },
onClick = { vm.selectPhaseSet(null); showRuleDropdown = false },
leadingIcon = {
if (selectedPhaseSet == null) Icon(Icons.Filled.Check, null)
}
)
// ── Presets ──────────────────────────────────────────
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(
text = {
Text(
"— Presets —",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
},
onClick = {},
enabled = false
)
presetPhaseSets.forEach { ruleSet ->
DropdownMenuItem(
text = { Text(ruleSet.name) },
onClick = { vm.selectPhaseSet(ruleSet); showRuleDropdown = false },
leadingIcon = {
if (selectedPhaseSet?.id == ruleSet.id) Icon(Icons.Filled.Check, null)
}
)
}
// ── User custom ──────────────────────────────────────
if (customPhaseSets.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(
text = {
Text(
"— Custom —",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
},
onClick = {},
enabled = false
)
customPhaseSets.forEach { ruleSet ->
DropdownMenuItem(
text = { Text(ruleSet.name) },
onClick = { vm.selectPhaseSet(ruleSet); showRuleDropdown = false },
leadingIcon = {
if (selectedPhaseSet?.id == ruleSet.id) Icon(Icons.Filled.Check, null)
}
)
}
}
}
}
}
// ── 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,377 @@
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.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.crsmthw.phase10tracker.R
import com.crsmthw.phase10tracker.data.ThemeMode
import com.crsmthw.phase10tracker.ui.HomeViewModel
// ── Home Screen ────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
vm: HomeViewModel,
themeMode: ThemeMode,
amoledBlack: Boolean,
onThemeModeChange: (ThemeMode) -> Unit,
onAmoledBlackChange: (Boolean) -> Unit,
onContinueGame: (Long) -> Unit,
onStartNew: () -> Unit,
onLeaderboard: () -> Unit,
onManagePlayers: () -> Unit,
onCustomRules: () -> Unit,
onAbout: () -> Unit
) {
val hasActiveGame by vm.hasActiveGame.collectAsState()
val activeGameId by vm.activeGameId.collectAsState()
var showResumeDialog by remember { mutableStateOf(false) }
var showThemeSheet by remember { mutableStateOf(false) }
// ── Resume dialog ──────────────────────────────────────────────────────────
if (showResumeDialog && hasActiveGame && activeGameId != null) {
ResumeGameDialog(
onContinue = {
showResumeDialog = false
onContinueGame(activeGameId!!)
},
onStartNew = {
showResumeDialog = false
onStartNew()
},
onDismiss = { showResumeDialog = false }
)
}
// ── Theme bottom sheet ─────────────────────────────────────────────────────
if (showThemeSheet) {
ThemePickerSheet(
currentMode = themeMode,
amoledBlack = amoledBlack,
onModeSelected = onThemeModeChange,
onAmoledBlackChange = onAmoledBlackChange,
onDismiss = { showThemeSheet = false }
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Phase 10",
style = MaterialTheme.typography.headlineMedium
)
},
actions = {
// Theme picker button — icon reflects current mode
IconButton(onClick = { showThemeSheet = true }) {
Icon(
imageVector = themeMode.icon(),
contentDescription = "Display theme"
)
}
IconButton(onClick = onAbout) {
Icon(Icons.Filled.Info, contentDescription = "About")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
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 Phases", style = MaterialTheme.typography.titleMedium)
}
}
}
}
// ── Theme icon helper ──────────────────────────────────────────────────────────
// The TopAppBar icon reflects the currently-active mode.
private fun ThemeMode.icon(): ImageVector = when (this) {
ThemeMode.LIGHT -> Icons.Outlined.LightMode
ThemeMode.DARK -> Icons.Outlined.DarkMode
ThemeMode.SYSTEM -> Icons.Outlined.BrightnessMedium
}
// ── Theme Picker BottomSheet ───────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThemePickerSheet(
currentMode: ThemeMode,
amoledBlack: Boolean,
onModeSelected: (ThemeMode) -> Unit,
onAmoledBlackChange: (Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// ── Header ────────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Outlined.Palette,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Text(
text = "Display Theme",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
// ── Mode selector (segmented buttons) ─────────────────────────────
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Mode",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemeModeOption.entries.forEachIndexed { index, option ->
SegmentedButton(
selected = currentMode == option.mode,
onClick = { onModeSelected(option.mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = ThemeModeOption.entries.size
),
icon = {
SegmentedButtonDefaults.Icon(active = currentMode == option.mode) {
Icon(
imageVector = option.icon,
contentDescription = null,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
)
}
}
) {
Text(option.label)
}
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// ── AMOLED toggle ─────────────────────────────────────────────────
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Outlined.Contrast,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
Column {
Text(
text = "AMOLED Pure Black",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Switch(
checked = amoledBlack,
onCheckedChange = onAmoledBlackChange
)
}
Text(
text = "Replaces dark backgrounds with true black — saves battery on OLED screens",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 34.dp)
)
}
}
}
}
// ── Segmented button data ──────────────────────────────────────────────────────
private enum class ThemeModeOption(
val mode: ThemeMode,
val label: String,
val icon: ImageVector
) {
SYSTEM(ThemeMode.SYSTEM, "Auto", Icons.Outlined.BrightnessMedium),
LIGHT (ThemeMode.LIGHT, "Light", Icons.Outlined.LightMode),
DARK (ThemeMode.DARK, "Dark", Icons.Outlined.DarkMode),
}
// ── Resume Game Dialog ─────────────────────────────────────────────────────────
@Composable
private fun ResumeGameDialog(
onContinue: () -> Unit,
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,310 @@
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.PhaseRule
import com.crsmthw.phase10tracker.data.model.RoundEntry
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 phaseRules by vm.phaseRules.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,
phaseRules = phaseRules,
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,
phaseRules: List<PhaseRule>,
isLast: Boolean,
onScoreChange: (String) -> Unit,
onTogglePhase: () -> Unit,
onNext: () -> Unit,
onDone: () -> Unit
) {
val phaseRule = remember(entry.currentPhase, phaseRules) {
phaseRules.getOrElse(entry.currentPhase - 1) { phaseRules.last() }
}
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,213 @@
package com.crsmthw.phase10tracker.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.crsmthw.phase10tracker.data.ThemeMode
// ── Custom fallback palette (used on devices below Android 12) ─────────────────
// 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),
tertiaryContainer = Color(0xFF004E43),
onTertiaryContainer = Color(0xFF77FADA),
background = Color(0xFF0E0E18),
onBackground = Color(0xFFE8E5FF),
surface = Color(0xFF141425),
onSurface = Color(0xFFE8E5FF),
surfaceVariant = Color(0xFF1F1F38),
onSurfaceVariant = Color(0xFFBBB8D4),
outline = Color(0xFF6B6890),
outlineVariant = Color(0xFF3A3860),
inverseSurface = Color(0xFFE8E5FF),
inverseOnSurface = Color(0xFF1A1830),
inversePrimary = Color(0xFF3A2FBF),
error = Color(0xFFFF6B6B),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
scrim = Color(0xFF000000),
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF3A2FBF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD8D0FF),
onPrimaryContainer = Color(0xFF1A0070),
secondary = Color(0xFF8A5E00),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFDFA0),
onSecondaryContainer = Color(0xFF3D2800),
tertiary = Color(0xFF006B5E),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFB8F0E6),
onTertiaryContainer = Color(0xFF004E43),
background = Color(0xFFF5F3FF),
onBackground = Color(0xFF1A1830),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1830),
surfaceVariant = Color(0xFFEAE6FF),
onSurfaceVariant = Color(0xFF4A4670),
outline = Color(0xFF7B78A0),
outlineVariant = Color(0xFFCCC9E8),
inverseSurface = Color(0xFF302E47),
inverseOnSurface = Color(0xFFF5F3FF),
inversePrimary = Color(0xFF9B8DFF),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
scrim = Color(0xFF000000),
)
// ── AMOLED Pure Black overlay ──────────────────────────────────────────────────
// Applied on top of whichever dark scheme is active (static or dynamic).
// Overrides only the surface / background tokens so accent colours are preserved.
private fun ColorScheme.withAmoledBlack(): ColorScheme = copy(
background = Color(0xFF000000),
onBackground = Color(0xFFE8E5FF),
surface = Color(0xFF000000),
onSurface = Color(0xFFE8E5FF),
surfaceVariant = Color(0xFF0D0D14),
surfaceTint = Color(0xFF9B8DFF),
inverseSurface = Color(0xFFE8E5FF),
inverseOnSurface = Color(0xFF000000),
)
// ── Smooth animated ColorScheme ────────────────────────────────────────────────
// Animates every individual colour token so transitions between
// light <-> dark <-> AMOLED are smooth rather than abrupt.
private val colorAnimSpec: AnimationSpec<Color> =
tween(durationMillis = 450, easing = FastOutSlowInEasing)
@Composable
private fun animatedColorScheme(target: ColorScheme): ColorScheme {
@Composable
fun ac(c: Color): Color = animateColorAsState(c, colorAnimSpec, label = "").value
return target.copy(
primary = ac(target.primary),
onPrimary = ac(target.onPrimary),
primaryContainer = ac(target.primaryContainer),
onPrimaryContainer = ac(target.onPrimaryContainer),
secondary = ac(target.secondary),
onSecondary = ac(target.onSecondary),
secondaryContainer = ac(target.secondaryContainer),
onSecondaryContainer = ac(target.onSecondaryContainer),
tertiary = ac(target.tertiary),
onTertiary = ac(target.onTertiary),
tertiaryContainer = ac(target.tertiaryContainer),
onTertiaryContainer = ac(target.onTertiaryContainer),
background = ac(target.background),
onBackground = ac(target.onBackground),
surface = ac(target.surface),
onSurface = ac(target.onSurface),
surfaceVariant = ac(target.surfaceVariant),
onSurfaceVariant = ac(target.onSurfaceVariant),
surfaceTint = ac(target.surfaceTint),
inverseSurface = ac(target.inverseSurface),
inverseOnSurface = ac(target.inverseOnSurface),
inversePrimary = ac(target.inversePrimary),
error = ac(target.error),
onError = ac(target.onError),
errorContainer = ac(target.errorContainer),
onErrorContainer = ac(target.onErrorContainer),
outline = ac(target.outline),
outlineVariant = ac(target.outlineVariant),
scrim = ac(target.scrim),
)
}
// ── Public theme entry-point ───────────────────────────────────────────────────
@Composable
fun Phase10Theme(
themeMode: ThemeMode = ThemeMode.SYSTEM,
amoledBlack: Boolean = false,
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val systemInDark = isSystemInDarkTheme()
val isDark = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> systemInDark
}
// Resolve base scheme (dynamic on API 31+, static fallback below)
val baseScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
isDark -> DarkColorScheme
else -> LightColorScheme
}
// Overlay AMOLED black on top of whichever dark scheme we resolved
val targetScheme = if (amoledBlack && isDark) baseScheme.withAmoledBlack() else baseScheme
// Every colour token animates individually -- buttery-smooth transitions
val colorScheme = animatedColorScheme(targetScheme)
// ── Keep system bar icon tint in sync with the active theme ───────────────
// SideEffect fires after every successful composition, so it stays in sync
// even after navigating away or dismissing overlays (sheets, dialogs, etc.)
// that temporarily take over the window appearance.
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).apply {
// Light bars = dark icons (readable on light backgrounds)
// Dark bars = light icons (readable on dark backgrounds)
isAppearanceLightStatusBars = !isDark
isAppearanceLightNavigationBars = !isDark
}
}
}
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)
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#3A2FBF" />
</shape>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Foreground icon — artwork by friend, adapted for Android adaptive icon.
Expanded viewport (543x543) centers and shrinks artwork to ~66dp
within the 108dp canvas, safely inside the 72dp safe zone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="618.2"
android:viewportHeight="618.2">
<!-- Translate to re-center artwork within expanded viewport -->
<group android:translateX="88.1" android:translateY="89.6">
<path
android:fillColor="#FFFFFF"
android:pathData="M201.675 71C207.819 71.0316 213.812 72.9122 218.868 76.3965C223.923 79.8807 227.808 84.8065 230.014 90.5293L308.296 277.229C309.705 281.29 310.488 285.821 310.331 289.883C310.018 301.444 302.816 313.005 291.074 317.849L175.686 365.5C171.615 367.219 167.387 368 163.317 368C157.185 367.832 151.235 365.879 146.201 362.382C141.166 358.884 137.265 353.995 134.979 348.314L57.3226 161.615C54.1898 153.97 54.2279 145.396 57.4281 137.779C60.6282 130.162 66.7285 124.125 74.388 120.995L189.619 73.3438C193.534 71.9377 197.605 71 201.675 71ZM139.08 198.93L126.429 210.438L130.519 220.814L142.99 208.848L164.338 263.012L176.698 258.141L151.439 194.059L139.08 198.93ZM206.781 177.27C203.299 175.293 198.684 175.437 192.937 177.702L178.747 183.295C174.933 184.798 172.116 186.673 170.298 188.917C168.511 191.09 167.584 193.659 167.518 196.623C167.503 199.567 168.227 202.896 169.691 206.608L181.478 236.513C183.663 242.056 186.532 245.832 190.085 247.84C193.618 249.796 198.258 249.642 204.005 247.377L218.195 241.784C223.941 239.519 227.414 236.476 228.611 232.655C229.838 228.763 229.359 224.045 227.174 218.502L215.387 188.597C213.182 183.003 210.313 179.226 206.781 177.27ZM193.448 188.166C195.024 187.545 196.375 187.394 197.5 187.715C198.676 188.015 199.643 188.604 200.4 189.48C201.207 190.337 201.863 191.255 202.366 192.231C202.868 193.208 203.269 194.078 203.57 194.841L214.351 221.97C214.631 222.682 214.932 223.592 215.251 224.7C215.55 225.757 215.697 226.875 215.691 228.052C215.736 229.209 215.43 230.299 214.776 231.321C214.172 232.323 213.056 233.145 211.429 233.786L204.029 236.703C201.384 237.745 199.217 237.689 197.528 236.533C195.889 235.358 194.518 233.371 193.415 230.574L182.395 202.835C181.272 199.987 180.919 197.599 181.334 195.673C181.781 193.675 183.326 192.156 185.971 191.113L193.448 188.166ZM362.623 103.809C378.749 110.37 386.109 128.65 379.69 144.586L341.644 236.139V95.0596L362.623 103.809ZM278.862 71C287.166 71 295.131 74.2924 301.003 80.1523C306.692 85.8291 309.966 93.4649 310.165 101.471L310.175 201.299L256.16 71H278.862Z"/>
</group>
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="618.2"
android:viewportHeight="618.2">
<group android:translateX="88.1" android:translateY="89.6">
<path
android:fillColor="#000000"
android:pathData="M201.675 71C207.819 71.0316 213.812 72.9122 218.868 76.3965C223.923 79.8807 227.808 84.8065 230.014 90.5293L308.296 277.229C309.705 281.29 310.488 285.821 310.331 289.883C310.018 301.444 302.816 313.005 291.074 317.849L175.686 365.5C171.615 367.219 167.387 368 163.317 368C157.185 367.832 151.235 365.879 146.201 362.382C141.166 358.884 137.265 353.995 134.979 348.314L57.3226 161.615C54.1898 153.97 54.2279 145.396 57.4281 137.779C60.6282 130.162 66.7285 124.125 74.388 120.995L189.619 73.3438C193.534 71.9377 197.605 71 201.675 71ZM139.08 198.93L126.429 210.438L130.519 220.814L142.99 208.848L164.338 263.012L176.698 258.141L151.439 194.059L139.08 198.93ZM206.781 177.27C203.299 175.293 198.684 175.437 192.937 177.702L178.747 183.295C174.933 184.798 172.116 186.673 170.298 188.917C168.511 191.09 167.584 193.659 167.518 196.623C167.503 199.567 168.227 202.896 169.691 206.608L181.478 236.513C183.663 242.056 186.532 245.832 190.085 247.84C193.618 249.796 198.258 249.642 204.005 247.377L218.195 241.784C223.941 239.519 227.414 236.476 228.611 232.655C229.838 228.763 229.359 224.045 227.174 218.502L215.387 188.597C213.182 183.003 210.313 179.226 206.781 177.27ZM193.448 188.166C195.024 187.545 196.375 187.394 197.5 187.715C198.676 188.015 199.643 188.604 200.4 189.48C201.207 190.337 201.863 191.255 202.366 192.231C202.868 193.208 203.269 194.078 203.57 194.841L214.351 221.97C214.631 222.682 214.932 223.592 215.251 224.7C215.55 225.757 215.697 226.875 215.691 228.052C215.736 229.209 215.43 230.299 214.776 231.321C214.172 232.323 213.056 233.145 211.429 233.786L204.029 236.703C201.384 237.745 199.217 237.689 197.528 236.533C195.889 235.358 194.518 233.371 193.415 230.574L182.395 202.835C181.272 199.987 180.919 197.599 181.334 195.673C181.781 193.675 183.326 192.156 185.971 191.113L193.448 188.166ZM362.623 103.809C378.749 110.37 386.109 128.65 379.69 144.586L341.644 236.139V95.0596L362.623 103.809ZM278.862 71C287.166 71 295.131 74.2924 301.003 80.1523C306.692 85.8291 309.966 93.4649 310.165 101.471L310.175 201.299L256.16 71H278.862Z"/>
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

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

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("com.google.devtools.ksp") version "2.3.8" apply false
}
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
Vendored
+84
View File
@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Phase10Tracker"
include(":app")