Compare commits
19 Commits
7f9f2fb854
..
v3.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ccc2b858 | |||
| 3637a1e236 | |||
| 84567bebaa | |||
| f70e082104 | |||
| cbcc2c0dbc | |||
| 383da18391 | |||
| 8eb3ea5c88 | |||
| cb458429e4 | |||
| 6eb41643be | |||
| 6bcc8c2f05 | |||
| c0e3d8af39 | |||
| be70a3d3de | |||
| fa4e651ada | |||
| 8be13ed8db | |||
| f9f3fc42e3 | |||
| 5b082591a2 | |||
| 115e4a32ac | |||
| 6f8c426520 | |||
| 04b11797d0 |
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 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" />
|
||||||
|
<img src="assets/screenshots/custom.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 people didn't deserve another ad-infested score tracker.*
|
||||||
@@ -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 = 7
|
||||||
|
versionName = "3.0.3"
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-keep class com.crsmthw.phase10tracker.data.** { *; }
|
||||||
|
-keepattributes *Annotation*
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "com.crsmthw.phase10tracker",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 7,
|
||||||
|
"versionName": "3.0.3",
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -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,187 @@
|
|||||||
|
package com.crsmthw.phase10tracker.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavOptionsBuilder
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Safe navigation helpers ───────────────────────────────────────────────────
|
||||||
|
// Guards every nav action behind a RESUMED check so rapid double-taps during
|
||||||
|
// transition animations can't pop extra destinations and cause a blank screen.
|
||||||
|
|
||||||
|
private fun NavController.navigateSafe(
|
||||||
|
route: String,
|
||||||
|
builder: NavOptionsBuilder.() -> Unit = {}
|
||||||
|
) {
|
||||||
|
if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) {
|
||||||
|
navigate(route, builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NavController.popSafe() {
|
||||||
|
if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) {
|
||||||
|
popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.navigateSafe(Routes.activeGame(gameId)) },
|
||||||
|
onStartNew = { navController.navigateSafe(Routes.GAME_SETUP) },
|
||||||
|
onLeaderboard = { navController.navigateSafe(Routes.LEADERBOARD) },
|
||||||
|
onManagePlayers = { navController.navigateSafe(Routes.PLAYER_ROSTER) },
|
||||||
|
onCustomRules = { navController.navigateSafe(Routes.CUSTOM_RULES) },
|
||||||
|
onAbout = { navController.navigateSafe(Routes.ABOUT) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.PLAYER_ROSTER) {
|
||||||
|
val vm: PlayerRosterViewModel = viewModel(factory = factory)
|
||||||
|
PlayerRosterScreen(
|
||||||
|
vm = vm,
|
||||||
|
onBack = { navController.popSafe() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.GAME_SETUP) {
|
||||||
|
val vm: GameSetupViewModel = viewModel(factory = factory)
|
||||||
|
GameSetupScreen(
|
||||||
|
vm = vm,
|
||||||
|
onGameStarted = { gameId ->
|
||||||
|
navController.navigateSafe(Routes.activeGame(gameId)) {
|
||||||
|
popUpTo(Routes.HOME)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = { navController.popSafe() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.navigateSafe(Routes.roundEntry(gameId)) },
|
||||||
|
onGameEnd = {
|
||||||
|
navController.navigateSafe(Routes.gameResults(gameId)) {
|
||||||
|
popUpTo(Routes.HOME)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onGameCancelled = {
|
||||||
|
navController.navigateSafe(Routes.HOME) {
|
||||||
|
popUpTo(Routes.HOME) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = { navController.popSafe() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.popSafe() },
|
||||||
|
onBack = { navController.popSafe() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.navigateSafe(Routes.HOME) {
|
||||||
|
popUpTo(Routes.HOME) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.LEADERBOARD) {
|
||||||
|
val vm: LeaderboardViewModel = viewModel(factory = factory)
|
||||||
|
LeaderboardScreen(
|
||||||
|
vm = vm,
|
||||||
|
onBack = { navController.popSafe() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.CUSTOM_RULES) {
|
||||||
|
val vm: CustomPhasesViewModel = viewModel(factory = factory)
|
||||||
|
CustomPhasesScreen(
|
||||||
|
vm = vm,
|
||||||
|
onBack = { navController.popSafe() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.ABOUT) {
|
||||||
|
AboutScreen(onBack = { navController.popSafe() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,315 @@
|
|||||||
|
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) {
|
||||||
|
Column(modifier = Modifier.navigationBarsPadding()) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
vm.submitRound()
|
||||||
|
},
|
||||||
|
enabled = vm.isValid(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
|
.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)
|
||||||
|
// Tell Compose the Scaffold padding already consumed those insets,
|
||||||
|
// so imePadding() only adds the *extra* keyboard space — not the
|
||||||
|
// full keyboard height on top of the already-padded bottomBar height.
|
||||||
|
// Without this, the gap = bottomBar height (the double-count).
|
||||||
|
.consumeWindowInsets(padding)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
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>
|
||||||
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 414 B |
|
After Width: | Height: | Size: 414 B |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Base M3 theme; Compose handles all actual colors/typography -->
|
||||||
|
<style name="Theme.Phase10Tracker" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 258 KiB |
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||