mirror of
https://github.com/cmclark00/TetriStats.git
synced 2025-05-18 15:15:21 +01:00
Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
|
71a2485aac | ||
|
aeb463fa88 | ||
|
b7d0382ecc | ||
|
f3bfa5650b | ||
|
857331566e |
15 changed files with 810 additions and 162 deletions
|
@ -83,11 +83,17 @@ dependencies {
|
||||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
|
||||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.6")
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.6")
|
||||||
|
|
||||||
|
// MPAndroidChart for progress visualization
|
||||||
|
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
|
// Gson for JSON serialization
|
||||||
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
|
|
@ -2,12 +2,16 @@ package com.accidentalproductions.tetristats
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
import com.accidentalproductions.tetristats.data.ScalingFactorsManager
|
||||||
import com.accidentalproductions.tetristats.data.ScoreDatabase
|
import com.accidentalproductions.tetristats.data.ScoreDatabase
|
||||||
|
|
||||||
class TetriStatsApplication : Application() {
|
class TetriStatsApplication : Application() {
|
||||||
lateinit var database: ScoreDatabase
|
lateinit var database: ScoreDatabase
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
lateinit var scalingFactorsManager: ScalingFactorsManager
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
try {
|
try {
|
||||||
|
@ -31,5 +35,8 @@ class TetriStatsApplication : Application() {
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the ScalingFactorsManager
|
||||||
|
scalingFactorsManager = ScalingFactorsManager(applicationContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -16,7 +16,9 @@ object ScalingFactors {
|
||||||
"Tetris DS" to RangeScalingFactor(3.0, 3.3, 4.5),
|
"Tetris DS" to RangeScalingFactor(3.0, 3.3, 4.5),
|
||||||
"Tetris Effect" to RangeScalingFactor(2.5, 3.8, 4.5),
|
"Tetris Effect" to RangeScalingFactor(2.5, 3.8, 4.5),
|
||||||
"Rosy Retrospection DX" to RangeScalingFactor(4.0, 1.5, 1.8),
|
"Rosy Retrospection DX" to RangeScalingFactor(4.0, 1.5, 1.8),
|
||||||
"Apotris" to RangeScalingFactor(1.8, 3.8, 4.4)
|
"Apotris" to RangeScalingFactor(1.8, 3.8, 4.4),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(2.0, 2.5, 3.0),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(2.2, 2.8, 3.5)
|
||||||
),
|
),
|
||||||
"Game Boy Tetris" to mapOf(
|
"Game Boy Tetris" to mapOf(
|
||||||
"NES Tetris" to 1.33,
|
"NES Tetris" to 1.33,
|
||||||
|
@ -24,7 +26,9 @@ object ScalingFactors {
|
||||||
"Tetris DS" to RangeScalingFactor(4.0, 2.0, 2.0),
|
"Tetris DS" to RangeScalingFactor(4.0, 2.0, 2.0),
|
||||||
"Tetris Effect" to RangeScalingFactor(4.0, 2.3, 2.3),
|
"Tetris Effect" to RangeScalingFactor(4.0, 2.3, 2.3),
|
||||||
"Rosy Retrospection DX" to 1.1,
|
"Rosy Retrospection DX" to 1.1,
|
||||||
"Apotris" to RangeScalingFactor(1.33, 1.33, 2.33)
|
"Apotris" to RangeScalingFactor(1.33, 1.33, 2.33),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(1.5, 1.8, 2.0),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(1.6, 1.9, 2.1)
|
||||||
),
|
),
|
||||||
"Tetris DX" to mapOf(
|
"Tetris DX" to mapOf(
|
||||||
"NES Tetris" to 1.33,
|
"NES Tetris" to 1.33,
|
||||||
|
@ -32,7 +36,9 @@ object ScalingFactors {
|
||||||
"Tetris DS" to RangeScalingFactor(4.0, 2.0, 2.0),
|
"Tetris DS" to RangeScalingFactor(4.0, 2.0, 2.0),
|
||||||
"Tetris Effect" to RangeScalingFactor(4.0, 2.3, 2.3),
|
"Tetris Effect" to RangeScalingFactor(4.0, 2.3, 2.3),
|
||||||
"Rosy Retrospection DX" to 1.1,
|
"Rosy Retrospection DX" to 1.1,
|
||||||
"Apotris" to RangeScalingFactor(1.33, 1.33, 2.33)
|
"Apotris" to RangeScalingFactor(1.33, 1.33, 2.33),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(1.5, 1.8, 2.0),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(1.6, 1.9, 2.1)
|
||||||
),
|
),
|
||||||
"Tetris DS" to mapOf(
|
"Tetris DS" to mapOf(
|
||||||
"NES Tetris" to RangeScalingFactor(0.33, 0.3, 0.22),
|
"NES Tetris" to RangeScalingFactor(0.33, 0.3, 0.22),
|
||||||
|
@ -40,7 +46,9 @@ object ScalingFactors {
|
||||||
"Tetris DX" to RangeScalingFactor(0.25, 0.5, 0.5),
|
"Tetris DX" to RangeScalingFactor(0.25, 0.5, 0.5),
|
||||||
"Tetris Effect" to RangeScalingFactor(0.83, 0.91, 1.0),
|
"Tetris Effect" to RangeScalingFactor(0.83, 0.91, 1.0),
|
||||||
"Rosy Retrospection DX" to RangeScalingFactor(0.25, 0.91, 0.67),
|
"Rosy Retrospection DX" to RangeScalingFactor(0.25, 0.91, 0.67),
|
||||||
"Apotris" to RangeScalingFactor(0.33, 0.67, 0.9)
|
"Apotris" to RangeScalingFactor(0.33, 0.67, 0.9),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(0.4, 0.5, 0.6),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(0.45, 0.55, 0.65)
|
||||||
),
|
),
|
||||||
"Tetris Effect" to mapOf(
|
"Tetris Effect" to mapOf(
|
||||||
"NES Tetris" to RangeScalingFactor(0.4, 0.26, 0.22),
|
"NES Tetris" to RangeScalingFactor(0.4, 0.26, 0.22),
|
||||||
|
@ -48,7 +56,9 @@ object ScalingFactors {
|
||||||
"Tetris DX" to RangeScalingFactor(0.25, 0.43, 0.43),
|
"Tetris DX" to RangeScalingFactor(0.25, 0.43, 0.43),
|
||||||
"Tetris DS" to RangeScalingFactor(1.2, 1.1, 1.0),
|
"Tetris DS" to RangeScalingFactor(1.2, 1.1, 1.0),
|
||||||
"Rosy Retrospection DX" to RangeScalingFactor(0.25, 0.43, 0.57),
|
"Rosy Retrospection DX" to RangeScalingFactor(0.25, 0.43, 0.57),
|
||||||
"Apotris" to RangeScalingFactor(0.33, 0.67, 0.85)
|
"Apotris" to RangeScalingFactor(0.33, 0.67, 0.85),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(0.45, 0.55, 0.65),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(0.5, 0.6, 0.7)
|
||||||
),
|
),
|
||||||
"Rosy Retrospection DX" to mapOf(
|
"Rosy Retrospection DX" to mapOf(
|
||||||
"NES Tetris" to RangeScalingFactor(0.25, 0.67, 0.57),
|
"NES Tetris" to RangeScalingFactor(0.25, 0.67, 0.57),
|
||||||
|
@ -56,7 +66,9 @@ object ScalingFactors {
|
||||||
"Tetris DX" to 0.91,
|
"Tetris DX" to 0.91,
|
||||||
"Tetris DS" to RangeScalingFactor(4.0, 1.5, 1.8),
|
"Tetris DS" to RangeScalingFactor(4.0, 1.5, 1.8),
|
||||||
"Tetris Effect" to RangeScalingFactor(4.0, 2.3, 1.8),
|
"Tetris Effect" to RangeScalingFactor(4.0, 2.3, 1.8),
|
||||||
"Apotris" to RangeScalingFactor(1.1, 0.67, 0.5)
|
"Apotris" to RangeScalingFactor(1.1, 0.67, 0.5),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(1.3, 1.5, 1.7),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(1.4, 1.6, 1.8)
|
||||||
),
|
),
|
||||||
"Apotris" to mapOf(
|
"Apotris" to mapOf(
|
||||||
"NES Tetris" to RangeScalingFactor(0.56, 0.26, 0.23),
|
"NES Tetris" to RangeScalingFactor(0.56, 0.26, 0.23),
|
||||||
|
@ -64,7 +76,29 @@ object ScalingFactors {
|
||||||
"Tetris DX" to RangeScalingFactor(0.75, 0.75, 0.5),
|
"Tetris DX" to RangeScalingFactor(0.75, 0.75, 0.5),
|
||||||
"Tetris DS" to RangeScalingFactor(3.0, 1.5, 1.0),
|
"Tetris DS" to RangeScalingFactor(3.0, 1.5, 1.0),
|
||||||
"Tetris Effect" to RangeScalingFactor(3.0, 1.7, 1.2),
|
"Tetris Effect" to RangeScalingFactor(3.0, 1.7, 1.2),
|
||||||
"Rosy Retrospection DX" to RangeScalingFactor(1.1, 0.67, 0.5)
|
"Rosy Retrospection DX" to RangeScalingFactor(1.1, 0.67, 0.5),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(1.2, 0.9, 0.7),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(1.3, 1.0, 0.8)
|
||||||
|
),
|
||||||
|
"Modretro Tetris" to mapOf(
|
||||||
|
"NES Tetris" to RangeScalingFactor(0.5, 0.4, 0.33),
|
||||||
|
"Game Boy Tetris" to RangeScalingFactor(0.67, 0.56, 0.5),
|
||||||
|
"Tetris DX" to RangeScalingFactor(0.67, 0.56, 0.5),
|
||||||
|
"Tetris DS" to RangeScalingFactor(2.5, 2.0, 1.67),
|
||||||
|
"Tetris Effect" to RangeScalingFactor(2.22, 1.82, 1.54),
|
||||||
|
"Rosy Retrospection DX" to RangeScalingFactor(0.77, 0.67, 0.59),
|
||||||
|
"Apotris" to RangeScalingFactor(0.83, 1.11, 1.43),
|
||||||
|
"Tetris Mobile" to RangeScalingFactor(1.1, 1.1, 1.1)
|
||||||
|
),
|
||||||
|
"Tetris Mobile" to mapOf(
|
||||||
|
"NES Tetris" to RangeScalingFactor(0.45, 0.36, 0.29),
|
||||||
|
"Game Boy Tetris" to RangeScalingFactor(0.63, 0.53, 0.48),
|
||||||
|
"Tetris DX" to RangeScalingFactor(0.63, 0.53, 0.48),
|
||||||
|
"Tetris DS" to RangeScalingFactor(2.22, 1.82, 1.54),
|
||||||
|
"Tetris Effect" to RangeScalingFactor(2.0, 1.67, 1.43),
|
||||||
|
"Rosy Retrospection DX" to RangeScalingFactor(0.71, 0.63, 0.56),
|
||||||
|
"Apotris" to RangeScalingFactor(0.77, 1.0, 1.25),
|
||||||
|
"Modretro Tetris" to RangeScalingFactor(0.91, 0.91, 0.91)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,166 @@
|
||||||
|
package com.accidentalproductions.tetristats.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the learning and persistence of scaling factors based on user input
|
||||||
|
*/
|
||||||
|
class ScalingFactorsManager(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
// In-memory cache of learned factors
|
||||||
|
private var learnedFactors: MutableMap<String, MutableMap<String, LearningData>> = loadLearnedFactors()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the scaling factor based on a new sample provided by the user
|
||||||
|
* @param fromGame The source game
|
||||||
|
* @param toGame The target game
|
||||||
|
* @param fromScore The original score
|
||||||
|
* @param toScore The user-provided equivalent score
|
||||||
|
*/
|
||||||
|
suspend fun updateScalingFactor(fromGame: String, toGame: String, fromScore: Int, toScore: Int) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Calculate the actual scaling factor from this sample
|
||||||
|
val actualFactor = toScore.toDouble() / fromScore.toDouble()
|
||||||
|
|
||||||
|
// Get or create learning data for this game conversion
|
||||||
|
val gameFactors = learnedFactors.getOrPut(fromGame) { mutableMapOf() }
|
||||||
|
val learningData = gameFactors.getOrPut(toGame) { LearningData() }
|
||||||
|
|
||||||
|
// Determine which bucket this score falls into
|
||||||
|
val bucket = when {
|
||||||
|
fromScore < 100000 -> ScoreBucket.LOW
|
||||||
|
fromScore < 500000 -> ScoreBucket.MID
|
||||||
|
else -> ScoreBucket.HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the appropriate bucket
|
||||||
|
when (bucket) {
|
||||||
|
ScoreBucket.LOW -> {
|
||||||
|
learningData.lowScoreSamples++
|
||||||
|
learningData.lowScoreTotal += actualFactor
|
||||||
|
}
|
||||||
|
ScoreBucket.MID -> {
|
||||||
|
learningData.midScoreSamples++
|
||||||
|
learningData.midScoreTotal += actualFactor
|
||||||
|
}
|
||||||
|
ScoreBucket.HIGH -> {
|
||||||
|
learningData.highScoreSamples++
|
||||||
|
learningData.highScoreTotal += actualFactor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated data
|
||||||
|
saveLearnedFactors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the learned scaling factor for a conversion, falling back to default if no samples
|
||||||
|
*/
|
||||||
|
fun getLearnedScalingFactor(fromGame: String, toGame: String, score: Int): Double {
|
||||||
|
// Check if we have learned data for this conversion
|
||||||
|
val learningData = learnedFactors[fromGame]?.get(toGame)
|
||||||
|
|
||||||
|
// If no learning data, fall back to default
|
||||||
|
if (learningData == null) {
|
||||||
|
return ScalingFactors.getScalingFactor(fromGame, toGame, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which bucket this score falls into
|
||||||
|
return when {
|
||||||
|
score < 100000 -> {
|
||||||
|
if (learningData.lowScoreSamples > 0) {
|
||||||
|
learningData.lowScoreTotal / learningData.lowScoreSamples
|
||||||
|
} else {
|
||||||
|
ScalingFactors.getScalingFactor(fromGame, toGame, score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score < 500000 -> {
|
||||||
|
if (learningData.midScoreSamples > 0) {
|
||||||
|
learningData.midScoreTotal / learningData.midScoreSamples
|
||||||
|
} else {
|
||||||
|
ScalingFactors.getScalingFactor(fromGame, toGame, score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (learningData.highScoreSamples > 0) {
|
||||||
|
learningData.highScoreTotal / learningData.highScoreSamples
|
||||||
|
} else {
|
||||||
|
ScalingFactors.getScalingFactor(fromGame, toGame, score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of samples collected for this game conversion
|
||||||
|
*/
|
||||||
|
fun getSampleCount(fromGame: String, toGame: String): Int {
|
||||||
|
val learningData = learnedFactors[fromGame]?.get(toGame) ?: return 0
|
||||||
|
return learningData.lowScoreSamples + learningData.midScoreSamples + learningData.highScoreSamples
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all learned scaling factors
|
||||||
|
*/
|
||||||
|
suspend fun resetAllFactors() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
learnedFactors.clear()
|
||||||
|
saveLearnedFactors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets learned scaling factors for a specific game conversion
|
||||||
|
*/
|
||||||
|
suspend fun resetFactors(fromGame: String, toGame: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
learnedFactors[fromGame]?.remove(toGame)
|
||||||
|
saveLearnedFactors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLearnedFactors(): MutableMap<String, MutableMap<String, LearningData>> {
|
||||||
|
val json = prefs.getString(KEY_LEARNED_FACTORS, null) ?: return mutableMapOf()
|
||||||
|
val type = object : TypeToken<MutableMap<String, MutableMap<String, LearningData>>>() {}.type
|
||||||
|
return try {
|
||||||
|
gson.fromJson(json, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
mutableMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLearnedFactors() {
|
||||||
|
val json = gson.toJson(learnedFactors)
|
||||||
|
prefs.edit().putString(KEY_LEARNED_FACTORS, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class to track learning progress for a specific game conversion
|
||||||
|
*/
|
||||||
|
data class LearningData(
|
||||||
|
var lowScoreSamples: Int = 0,
|
||||||
|
var lowScoreTotal: Double = 0.0,
|
||||||
|
var midScoreSamples: Int = 0,
|
||||||
|
var midScoreTotal: Double = 0.0,
|
||||||
|
var highScoreSamples: Int = 0,
|
||||||
|
var highScoreTotal: Double = 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ScoreBucket {
|
||||||
|
LOW, MID, HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "ScalingFactorsPrefs"
|
||||||
|
private const val KEY_LEARNED_FACTORS = "learned_factors"
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,9 +12,15 @@ interface ScoreDao {
|
||||||
@Query("SELECT * FROM scores WHERE gameVersion = :gameVersion")
|
@Query("SELECT * FROM scores WHERE gameVersion = :gameVersion")
|
||||||
fun getScoresForGame(gameVersion: String): LiveData<List<Score>>
|
fun getScoresForGame(gameVersion: String): LiveData<List<Score>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM scores WHERE gameVersion = :gameVersion ORDER BY dateRecorded ASC")
|
||||||
|
fun getScoresForGameByDate(gameVersion: String): LiveData<List<Score>>
|
||||||
|
|
||||||
@Query("SELECT DISTINCT gameVersion FROM scores")
|
@Query("SELECT DISTINCT gameVersion FROM scores")
|
||||||
fun getGamesWithScores(): LiveData<List<String>>
|
fun getGamesWithScores(): LiveData<List<String>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM scores")
|
||||||
|
fun getTotalScoreCount(): LiveData<Int>
|
||||||
|
|
||||||
@Query("SELECT AVG(scoreValue) FROM scores WHERE gameVersion = :gameVersion")
|
@Query("SELECT AVG(scoreValue) FROM scores WHERE gameVersion = :gameVersion")
|
||||||
fun getAverageScore(gameVersion: String): LiveData<Double>
|
fun getAverageScore(gameVersion: String): LiveData<Double>
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,10 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.accidentalproductions.tetristats.data.Score
|
import com.accidentalproductions.tetristats.data.Score
|
||||||
import com.accidentalproductions.tetristats.databinding.FragmentEntryBinding
|
import com.accidentalproductions.tetristats.databinding.FragmentEntryBinding
|
||||||
|
|
||||||
|
@ -14,6 +16,10 @@ class EntryFragment : Fragment() {
|
||||||
private var _binding: FragmentEntryBinding? = null
|
private var _binding: FragmentEntryBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: EntryViewModel by viewModels { EntryViewModelFactory(requireActivity().application) }
|
private val viewModel: EntryViewModel by viewModels { EntryViewModelFactory(requireActivity().application) }
|
||||||
|
private lateinit var equivalentScoreAdapter: EquivalentScoreAdapter
|
||||||
|
|
||||||
|
// Flag to track if we already showed the requirements toast
|
||||||
|
private var hasShownRequirementsToast = false
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
|
@ -28,8 +34,74 @@ class EntryFragment : Fragment() {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
setupGameVersionDropdown()
|
setupGameVersionDropdown()
|
||||||
setupScoreConverter()
|
setupRecyclerView()
|
||||||
setupSubmitButton()
|
setupSubmitButton()
|
||||||
|
setupAutoAnalysis()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Refresh conversions when returning to this fragment
|
||||||
|
refreshConversions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force refresh the conversions using the last submitted values
|
||||||
|
*/
|
||||||
|
private fun refreshConversions() {
|
||||||
|
if (viewModel.showConversion.value == true) {
|
||||||
|
// If we have last submitted values, regenerate conversions
|
||||||
|
val game = viewModel.lastSubmittedGame.value
|
||||||
|
val score = viewModel.lastSubmittedScore.value
|
||||||
|
|
||||||
|
if (game != null && score != null) {
|
||||||
|
viewModel.refreshEquivalentScores(game, score)
|
||||||
|
|
||||||
|
// Make sure UI updates immediately by forcing an adapter refresh
|
||||||
|
viewModel.equivalentScores.value?.let { scores ->
|
||||||
|
equivalentScoreAdapter.submitList(null) // Clear first
|
||||||
|
equivalentScoreAdapter.submitList(scores) // Then add new list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure card is visible
|
||||||
|
updateAnalysisCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the analysis card visibility and contents based on current state
|
||||||
|
*/
|
||||||
|
private fun updateAnalysisCard() {
|
||||||
|
if (viewModel.showConversion.value != true) {
|
||||||
|
binding.cardAnalysisResults.visibility = View.GONE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val game = viewModel.lastSubmittedGame.value
|
||||||
|
val score = viewModel.lastSubmittedScore.value
|
||||||
|
|
||||||
|
if (game != null && score != null) {
|
||||||
|
// Get the list of games with scores
|
||||||
|
val playedGames = viewModel.gamesWithScores.value ?: listOf()
|
||||||
|
|
||||||
|
// Make sure we don't show the source game in the equivalent dropdown
|
||||||
|
val filteredGames = playedGames.filter { it != game }
|
||||||
|
if (filteredGames.isNotEmpty()) {
|
||||||
|
binding.textViewOriginalScore.text = "Your $game score of ${"%,d".format(score)} is equivalent to:"
|
||||||
|
binding.cardAnalysisResults.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
val filteredAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, filteredGames)
|
||||||
|
binding.autoCompleteEquivalentGame.setAdapter(filteredAdapter)
|
||||||
|
|
||||||
|
// Select first game by default
|
||||||
|
binding.autoCompleteEquivalentGame.setText(filteredGames[0], false)
|
||||||
|
viewModel.setSelectedEquivalentGame(filteredGames[0])
|
||||||
|
} else {
|
||||||
|
// If no other games to convert to, hide the card
|
||||||
|
binding.cardAnalysisResults.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupGameVersionDropdown() {
|
private fun setupGameVersionDropdown() {
|
||||||
|
@ -40,64 +112,85 @@ class EntryFragment : Fragment() {
|
||||||
"Tetris DS",
|
"Tetris DS",
|
||||||
"Tetris Effect",
|
"Tetris Effect",
|
||||||
"Rosy Retrospection DX",
|
"Rosy Retrospection DX",
|
||||||
"Apotris"
|
"Apotris",
|
||||||
|
"Modretro Tetris",
|
||||||
|
"Tetris Mobile"
|
||||||
)
|
)
|
||||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games)
|
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games)
|
||||||
binding.autoCompleteGameVersion.setAdapter(adapter)
|
binding.autoCompleteGameVersion.setAdapter(adapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupScoreConverter() {
|
private fun setupRecyclerView() {
|
||||||
// Setup "From Game" dropdown with games that have scores
|
equivalentScoreAdapter = EquivalentScoreAdapter()
|
||||||
|
binding.recyclerViewEquivalentScores.apply {
|
||||||
|
adapter = equivalentScoreAdapter
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupAutoAnalysis() {
|
||||||
|
// Hide the analysis card by default
|
||||||
|
binding.cardAnalysisResults.visibility = View.GONE
|
||||||
|
|
||||||
|
// Observe if we should show conversions
|
||||||
|
viewModel.showConversion.observe(viewLifecycleOwner) { shouldShow ->
|
||||||
|
// No need to show toast here - we'll do it only after score submission
|
||||||
|
if (shouldShow) {
|
||||||
|
// Update card when showConversion changes
|
||||||
|
updateAnalysisCard()
|
||||||
|
} else {
|
||||||
|
binding.cardAnalysisResults.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only setup equivalence UI when we have scores
|
||||||
viewModel.gamesWithScores.observe(viewLifecycleOwner) { games ->
|
viewModel.gamesWithScores.observe(viewLifecycleOwner) { games ->
|
||||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games)
|
// Setup the game dropdown for adding equivalents - only with played games
|
||||||
binding.autoCompleteFromGame.setAdapter(adapter)
|
if (games.isNotEmpty()) {
|
||||||
}
|
// Update card when games list changes
|
||||||
|
updateAnalysisCard()
|
||||||
// Update score selection when game is selected
|
|
||||||
binding.autoCompleteFromGame.setOnItemClickListener { _, _, _, _ ->
|
|
||||||
val selectedGame = binding.autoCompleteFromGame.text.toString()
|
|
||||||
viewModel.setSelectedFromGame(selectedGame)
|
|
||||||
updateScoreDropdown(selectedGame)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup "To Game" dropdown
|
|
||||||
val allGames = listOf(
|
|
||||||
"NES Tetris",
|
|
||||||
"Game Boy Tetris",
|
|
||||||
"Tetris DX",
|
|
||||||
"Tetris DS",
|
|
||||||
"Tetris Effect",
|
|
||||||
"Rosy Retrospection DX",
|
|
||||||
"Apotris"
|
|
||||||
)
|
|
||||||
val toGameAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, allGames)
|
|
||||||
binding.autoCompleteToGame.setAdapter(toGameAdapter)
|
|
||||||
|
|
||||||
// Handle score conversion
|
|
||||||
binding.buttonConvert.setOnClickListener {
|
|
||||||
viewModel.convertScore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe converted score
|
|
||||||
viewModel.convertedScore.observe(viewLifecycleOwner) { score ->
|
|
||||||
binding.cardConvertedScore.visibility = View.VISIBLE
|
|
||||||
binding.textViewConvertedScore.text = "%,d".format(score)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update selected games
|
|
||||||
binding.autoCompleteToGame.setOnItemClickListener { _, _, _, _ ->
|
|
||||||
viewModel.setSelectedToGame(binding.autoCompleteToGame.text.toString())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateScoreDropdown(gameVersion: String) {
|
// Update selected game
|
||||||
viewModel.getScoresForGame(gameVersion).observe(viewLifecycleOwner) { scores ->
|
binding.autoCompleteEquivalentGame.setOnItemClickListener { _, _, _, _ ->
|
||||||
val scoreStrings = scores.map { "${it.scoreValue} (Level ${it.endLevel ?: "?"})"}
|
val selectedGame = binding.autoCompleteEquivalentGame.text.toString()
|
||||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, scoreStrings)
|
viewModel.setSelectedEquivalentGame(selectedGame)
|
||||||
binding.spinnerScoreSelect.setAdapter(adapter)
|
}
|
||||||
|
|
||||||
binding.spinnerScoreSelect.setOnItemClickListener { _, _, position, _ ->
|
// Handle adding equivalent scores
|
||||||
viewModel.setSelectedScore(scores[position])
|
binding.buttonAddEquivalent.setOnClickListener {
|
||||||
|
val equivalentScore = binding.editTextEquivalentScore.text.toString().toIntOrNull()
|
||||||
|
if (equivalentScore != null) {
|
||||||
|
viewModel.addEquivalentScore(equivalentScore)
|
||||||
|
binding.editTextEquivalentScore.text?.clear()
|
||||||
|
Toast.makeText(context, "Equivalent score added! The converter is learning.", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Please enter a valid score", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe last submitted score details
|
||||||
|
viewModel.lastSubmittedGame.observe(viewLifecycleOwner) { _ ->
|
||||||
|
// Update the analysis card when last submitted game changes
|
||||||
|
updateAnalysisCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe last submitted score value
|
||||||
|
viewModel.lastSubmittedScore.observe(viewLifecycleOwner) { _ ->
|
||||||
|
// Update the analysis card when score changes
|
||||||
|
updateAnalysisCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe equivalent scores
|
||||||
|
viewModel.equivalentScores.observe(viewLifecycleOwner) { scores ->
|
||||||
|
if (scores.isNotEmpty()) {
|
||||||
|
// Force a clean update by clearing first
|
||||||
|
equivalentScoreAdapter.submitList(null)
|
||||||
|
equivalentScoreAdapter.submitList(scores)
|
||||||
|
} else if (viewModel.showConversion.value == true) {
|
||||||
|
// If we should be showing conversions but have no scores, probably no other games
|
||||||
|
binding.cardAnalysisResults.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,6 +212,24 @@ class EntryFragment : Fragment() {
|
||||||
linesCleared = linesCleared
|
linesCleared = linesCleared
|
||||||
)
|
)
|
||||||
clearInputs()
|
clearInputs()
|
||||||
|
|
||||||
|
// Force immediate refresh of conversions
|
||||||
|
if (viewModel.showConversion.value == false) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Enter at least 3 scores across 2 different games to see conversions",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
refreshConversions()
|
||||||
|
|
||||||
|
// Scroll down to show the analysis results
|
||||||
|
binding.root.post {
|
||||||
|
binding.root.fullScroll(View.FOCUS_DOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Please enter a game and score", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,49 +1,94 @@
|
||||||
package com.accidentalproductions.tetristats.ui.entry
|
package com.accidentalproductions.tetristats.ui.entry
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
|
import com.accidentalproductions.tetristats.TetriStatsApplication
|
||||||
import com.accidentalproductions.tetristats.data.Score
|
import com.accidentalproductions.tetristats.data.Score
|
||||||
import com.accidentalproductions.tetristats.data.ScoreDatabase
|
import com.accidentalproductions.tetristats.data.ScoreDatabase
|
||||||
import com.accidentalproductions.tetristats.data.ScalingFactors
|
import com.accidentalproductions.tetristats.data.ScalingFactors
|
||||||
|
import com.accidentalproductions.tetristats.data.ScalingFactorsManager
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class EntryViewModel(application: Application) : AndroidViewModel(application) {
|
class EntryViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val database = ScoreDatabase.getDatabase(application)
|
private val database = ScoreDatabase.getDatabase(application)
|
||||||
private val scoreDao = database.scoreDao()
|
private val scoreDao = database.scoreDao()
|
||||||
|
private val scalingFactorsManager = (application as TetriStatsApplication).scalingFactorsManager
|
||||||
|
|
||||||
|
// All games list for reference
|
||||||
|
private val allGames = listOf(
|
||||||
|
"NES Tetris",
|
||||||
|
"Game Boy Tetris",
|
||||||
|
"Tetris DX",
|
||||||
|
"Tetris DS",
|
||||||
|
"Tetris Effect",
|
||||||
|
"Rosy Retrospection DX",
|
||||||
|
"Apotris",
|
||||||
|
"Modretro Tetris",
|
||||||
|
"Tetris Mobile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track user played games and score counts
|
||||||
val gamesWithScores = scoreDao.getGamesWithScores()
|
val gamesWithScores = scoreDao.getGamesWithScores()
|
||||||
|
val totalScoreCount = scoreDao.getTotalScoreCount()
|
||||||
|
|
||||||
private val _selectedFromGame = MutableLiveData<String>()
|
// For auto-analysis
|
||||||
private val _selectedScore = MutableLiveData<Score>()
|
private val _lastSubmittedGame = MutableLiveData<String>()
|
||||||
private val _selectedToGame = MutableLiveData<String>()
|
private val _lastSubmittedScore = MutableLiveData<Int>()
|
||||||
private val _convertedScore = MutableLiveData<Int>()
|
private val _equivalentScores = MutableLiveData<List<EquivalentScore>>()
|
||||||
|
private val _showConversion = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
val convertedScore: LiveData<Int> = _convertedScore
|
// Current game selection for learning
|
||||||
|
private val _selectedEquivalentGame = MutableLiveData<String>()
|
||||||
|
|
||||||
|
val equivalentScores: LiveData<List<EquivalentScore>> = _equivalentScores
|
||||||
|
val lastSubmittedGame: LiveData<String> = _lastSubmittedGame
|
||||||
|
val lastSubmittedScore: LiveData<Int> = _lastSubmittedScore
|
||||||
|
val showConversion: LiveData<Boolean> = _showConversion
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Set up observers to update conversion criteria whenever relevant data changes
|
||||||
|
gamesWithScores.observeForever {
|
||||||
|
checkConversionCriteria()
|
||||||
|
}
|
||||||
|
|
||||||
|
totalScoreCount.observeForever {
|
||||||
|
checkConversionCriteria()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getScoresForGame(gameVersion: String): LiveData<List<Score>> {
|
fun getScoresForGame(gameVersion: String): LiveData<List<Score>> {
|
||||||
return scoreDao.getScoresForGame(gameVersion)
|
return scoreDao.getScoresForGame(gameVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSelectedFromGame(game: String) {
|
fun setSelectedEquivalentGame(game: String) {
|
||||||
_selectedFromGame.value = game
|
_selectedEquivalentGame.value = game
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSelectedScore(score: Score) {
|
/**
|
||||||
_selectedScore.value = score
|
* Check if we should show conversions based on score count and game count
|
||||||
|
*/
|
||||||
|
fun checkConversionCriteria() {
|
||||||
|
val scoreCount = totalScoreCount.value ?: 0
|
||||||
|
val gameCount = gamesWithScores.value?.size ?: 0
|
||||||
|
|
||||||
|
// Only show conversions if there are at least 3 scores across at least 2 games
|
||||||
|
val shouldShow = scoreCount >= 3 && gameCount >= 2
|
||||||
|
|
||||||
|
// For debugging
|
||||||
|
Log.d("TetriStats", "Checking conversion criteria: scores=$scoreCount, games=$gameCount, shouldShow=$shouldShow")
|
||||||
|
|
||||||
|
_showConversion.postValue(shouldShow)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSelectedToGame(game: String) {
|
/**
|
||||||
_selectedToGame.value = game
|
* Force refresh the equivalent scores - use this to ensure UI has latest values
|
||||||
}
|
*/
|
||||||
|
fun refreshEquivalentScores(fromGame: String, score: Int) {
|
||||||
fun convertScore() {
|
// Only refresh if conversions should be showing
|
||||||
val fromGame = _selectedFromGame.value
|
if (_showConversion.value == true) {
|
||||||
val score = _selectedScore.value
|
Log.d("TetriStats", "Refreshing equivalent scores for $fromGame score $score")
|
||||||
val toGame = _selectedToGame.value
|
generateEquivalentScores(fromGame, score)
|
||||||
|
|
||||||
if (fromGame != null && score != null && toGame != null) {
|
|
||||||
val convertedScore = ScalingFactors.convertScore(fromGame, toGame, score.scoreValue)
|
|
||||||
_convertedScore.value = convertedScore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,10 +108,77 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
linesCleared = linesCleared
|
linesCleared = linesCleared
|
||||||
)
|
)
|
||||||
scoreDao.insert(newScore)
|
scoreDao.insert(newScore)
|
||||||
|
|
||||||
|
// After inserting, update the last submitted values
|
||||||
|
_lastSubmittedGame.value = gameVersion // Use immediate value change instead of postValue
|
||||||
|
_lastSubmittedScore.value = score // Use immediate value change instead of postValue
|
||||||
|
|
||||||
|
// Immediately check conversion criteria with current values
|
||||||
|
checkConversionCriteria()
|
||||||
|
|
||||||
|
// Immediate refresh regardless if we just reached the criteria threshold
|
||||||
|
if (totalScoreCount.value ?: 0 >= 3 && (gamesWithScores.value?.size ?: 0) >= 2) {
|
||||||
|
generateEquivalentScores(gameVersion, score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates equivalent scores in played games based on the submitted score
|
||||||
|
*/
|
||||||
|
private fun generateEquivalentScores(fromGame: String, score: Int) {
|
||||||
|
val playedGames = gamesWithScores.value ?: listOf()
|
||||||
|
val equivalents = mutableListOf<EquivalentScore>()
|
||||||
|
|
||||||
|
// Generate equivalent scores for played games except the source game
|
||||||
|
for (game in playedGames) {
|
||||||
|
if (game != fromGame) {
|
||||||
|
// Get the learned scaling factor
|
||||||
|
val factor = scalingFactorsManager.getLearnedScalingFactor(fromGame, game, score)
|
||||||
|
val equivalentScore = (score * factor).toInt()
|
||||||
|
val sampleCount = scalingFactorsManager.getSampleCount(fromGame, game)
|
||||||
|
|
||||||
|
equivalents.add(
|
||||||
|
EquivalentScore(
|
||||||
|
gameName = game,
|
||||||
|
score = equivalentScore,
|
||||||
|
sampleCount = sampleCount,
|
||||||
|
usesDynamicFactor = sampleCount > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use setValue for immediate update on main thread rather than postValue
|
||||||
|
_equivalentScores.value = equivalents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a learning sample with an equivalent score in another game
|
||||||
|
*/
|
||||||
|
fun addEquivalentScore(equivalentScore: Int) {
|
||||||
|
val fromGame = _lastSubmittedGame.value
|
||||||
|
val originalScore = _lastSubmittedScore.value
|
||||||
|
val toGame = _selectedEquivalentGame.value
|
||||||
|
|
||||||
|
if (fromGame != null && originalScore != null && toGame != null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
scalingFactorsManager.updateScalingFactor(fromGame, toGame, originalScore, equivalentScore)
|
||||||
|
|
||||||
|
// Regenerate the equivalent scores to update the UI
|
||||||
|
generateEquivalentScores(fromGame, originalScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
// Remove our observers to prevent leaks
|
||||||
|
gamesWithScores.removeObserver { checkConversionCriteria() }
|
||||||
|
totalScoreCount.removeObserver { checkConversionCriteria() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class EntryViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
|
class EntryViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(EntryViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(EntryViewModel::class.java)) {
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.accidentalproductions.tetristats.ui.entry
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.accidentalproductions.tetristats.databinding.ItemEquivalentScoreBinding
|
||||||
|
|
||||||
|
class EquivalentScoreAdapter : ListAdapter<EquivalentScore, EquivalentScoreAdapter.ViewHolder>(EquivalentScoreDiffCallback()) {
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val binding = ItemEquivalentScoreBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return ViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
holder.bind(getItem(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(private val binding: ItemEquivalentScoreBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(item: EquivalentScore) {
|
||||||
|
binding.textViewGameName.text = item.gameName
|
||||||
|
binding.textViewEquivalentScore.text = "%,d".format(item.score)
|
||||||
|
|
||||||
|
// Show sample count if any samples exist
|
||||||
|
if (item.sampleCount > 0) {
|
||||||
|
binding.textViewSampleCount.visibility = android.view.View.VISIBLE
|
||||||
|
binding.textViewSampleCount.text = "Based on ${item.sampleCount} learning ${if (item.sampleCount == 1) "sample" else "samples"}"
|
||||||
|
} else {
|
||||||
|
binding.textViewSampleCount.visibility = android.view.View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EquivalentScoreDiffCallback : DiffUtil.ItemCallback<EquivalentScore>() {
|
||||||
|
override fun areItemsTheSame(oldItem: EquivalentScore, newItem: EquivalentScore): Boolean {
|
||||||
|
return oldItem.gameName == newItem.gameName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: EquivalentScore, newItem: EquivalentScore): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing an equivalent score in another game
|
||||||
|
*/
|
||||||
|
data class EquivalentScore(
|
||||||
|
val gameName: String,
|
||||||
|
val score: Int,
|
||||||
|
val sampleCount: Int = 0,
|
||||||
|
val usesDynamicFactor: Boolean = false
|
||||||
|
)
|
|
@ -1,5 +1,6 @@
|
||||||
package com.accidentalproductions.tetristats.ui.stats
|
package com.accidentalproductions.tetristats.ui.stats
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -8,7 +9,16 @@ import android.widget.ArrayAdapter
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.accidentalproductions.tetristats.R
|
||||||
import com.accidentalproductions.tetristats.databinding.FragmentStatsBinding
|
import com.accidentalproductions.tetristats.databinding.FragmentStatsBinding
|
||||||
|
import com.github.mikephil.charting.components.XAxis
|
||||||
|
import com.github.mikephil.charting.data.Entry
|
||||||
|
import com.github.mikephil.charting.data.LineData
|
||||||
|
import com.github.mikephil.charting.data.LineDataSet
|
||||||
|
import com.github.mikephil.charting.formatter.ValueFormatter
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class StatsFragment : Fragment() {
|
class StatsFragment : Fragment() {
|
||||||
private var _binding: FragmentStatsBinding? = null
|
private var _binding: FragmentStatsBinding? = null
|
||||||
|
@ -30,6 +40,7 @@ class StatsFragment : Fragment() {
|
||||||
|
|
||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
setupGameFilter()
|
setupGameFilter()
|
||||||
|
setupProgressChart()
|
||||||
observeStats()
|
observeStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,6 +64,64 @@ class StatsFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupProgressChart() {
|
||||||
|
with(binding.chartProgress) {
|
||||||
|
description.isEnabled = false
|
||||||
|
legend.isEnabled = true
|
||||||
|
setTouchEnabled(true)
|
||||||
|
setDrawGridBackground(false)
|
||||||
|
isDragEnabled = true
|
||||||
|
setScaleEnabled(true)
|
||||||
|
setPinchZoom(true)
|
||||||
|
|
||||||
|
axisRight.isEnabled = false
|
||||||
|
|
||||||
|
xAxis.position = XAxis.XAxisPosition.BOTTOM
|
||||||
|
xAxis.granularity = 1f
|
||||||
|
xAxis.setDrawGridLines(false)
|
||||||
|
|
||||||
|
axisLeft.setDrawGridLines(true)
|
||||||
|
axisLeft.axisMinimum = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProgressChart(scores: List<Entry>, dates: List<Long>) {
|
||||||
|
if (scores.isEmpty()) {
|
||||||
|
binding.chartProgress.clear()
|
||||||
|
binding.chartProgress.invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataSet = LineDataSet(scores, "Score Progress").apply {
|
||||||
|
mode = LineDataSet.Mode.CUBIC_BEZIER
|
||||||
|
color = resources.getColor(R.color.tetris_navy, null)
|
||||||
|
lineWidth = 2f
|
||||||
|
setDrawCircles(true)
|
||||||
|
setCircleColor(resources.getColor(R.color.tetris_navy, null))
|
||||||
|
circleRadius = 4f
|
||||||
|
setDrawValues(false)
|
||||||
|
highLightColor = Color.rgb(244, 117, 117)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lineData = LineData(dataSet)
|
||||||
|
binding.chartProgress.data = lineData
|
||||||
|
|
||||||
|
// Format X-axis labels (dates)
|
||||||
|
val dateFormat = SimpleDateFormat("MM/dd", Locale.getDefault())
|
||||||
|
binding.chartProgress.xAxis.valueFormatter = object : ValueFormatter() {
|
||||||
|
override fun getFormattedValue(value: Float): String {
|
||||||
|
val index = value.toInt()
|
||||||
|
return if (index >= 0 && index < dates.size) {
|
||||||
|
dateFormat.format(Date(dates[index]))
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chartProgress.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeStats() {
|
private fun observeStats() {
|
||||||
viewModel.filteredScores.observe(viewLifecycleOwner) { scores ->
|
viewModel.filteredScores.observe(viewLifecycleOwner) { scores ->
|
||||||
scoreAdapter.submitList(scores)
|
scoreAdapter.submitList(scores)
|
||||||
|
@ -65,6 +134,24 @@ class StatsFragment : Fragment() {
|
||||||
viewModel.highScore.observe(viewLifecycleOwner) { highScore ->
|
viewModel.highScore.observe(viewLifecycleOwner) { highScore ->
|
||||||
binding.textViewHighScore.text = "%,d".format(highScore)
|
binding.textViewHighScore.text = "%,d".format(highScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModel.scoresByDate.observe(viewLifecycleOwner) { scores ->
|
||||||
|
// Convert scores to entries for the chart
|
||||||
|
if (scores.isNotEmpty()) {
|
||||||
|
val entries = mutableListOf<Entry>()
|
||||||
|
val dates = mutableListOf<Long>()
|
||||||
|
|
||||||
|
scores.forEachIndexed { index, score ->
|
||||||
|
entries.add(Entry(index.toFloat(), score.scoreValue.toFloat()))
|
||||||
|
dates.add(score.dateRecorded)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressChart(entries, dates)
|
||||||
|
} else {
|
||||||
|
binding.chartProgress.clear()
|
||||||
|
binding.chartProgress.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|
|
@ -18,6 +18,10 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
scoreDao.getScoresForGame(game)
|
scoreDao.getScoresForGame(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scoresByDate: LiveData<List<Score>> = _selectedGame.switchMap { game ->
|
||||||
|
scoreDao.getScoresForGameByDate(game)
|
||||||
|
}
|
||||||
|
|
||||||
val averageScore: LiveData<Double> = _selectedGame.switchMap { game ->
|
val averageScore: LiveData<Double> = _selectedGame.switchMap { game ->
|
||||||
scoreDao.getAverageScore(game)
|
scoreDao.getAverageScore(game)
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,10 +112,12 @@
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Score Converter Card -->
|
<!-- Automatic Analysis Card (hidden by default, shown after submission) -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardAnalysisResults"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
app:cardElevation="4dp">
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
@ -127,92 +129,81 @@
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Score Converter"
|
android:text="Equivalent Scores in Other Games"
|
||||||
android:textAppearance="?attr/textAppearanceHeadline6"
|
android:textAppearance="?attr/textAppearanceHeadline6"
|
||||||
android:layout_marginBottom="16dp"/>
|
android:layout_marginBottom="16dp"/>
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<TextView
|
||||||
|
android:id="@+id/textViewOriginalScore"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
|
tools:text="Your NES Tetris score of 500,000 is equivalent to:"/>
|
||||||
|
|
||||||
<AutoCompleteTextView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/autoCompleteFromGame"
|
android:id="@+id/recyclerViewEquivalentScores"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="From Game"
|
android:layout_marginBottom="8dp"/>
|
||||||
android:inputType="none"/>
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<!-- Learning feedback section -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Improve Conversions"
|
||||||
|
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:text="Do you know the equivalent score in another game? Add it to help improve future conversions:"
|
||||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
|
android:textAppearance="?attr/textAppearanceBody2"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
<AutoCompleteTextView
|
|
||||||
android:id="@+id/spinnerScoreSelect"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="Select Score"
|
|
||||||
android:inputType="none"/>
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
|
|
||||||
|
|
||||||
<AutoCompleteTextView
|
|
||||||
android:id="@+id/autoCompleteToGame"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="To Game"
|
|
||||||
android:inputType="none"/>
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/buttonConvert"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Convert Score"
|
|
||||||
android:layout_marginBottom="16dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:id="@+id/cardConvertedScore"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:cardBackgroundColor="@color/tetris_turquoise"
|
|
||||||
app:cardElevation="2dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="horizontal"
|
||||||
android:padding="16dp">
|
android:layout_marginBottom="8dp">
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Converted Score"
|
android:layout_weight="1"
|
||||||
android:textColor="@color/tetris_navy"
|
android:layout_marginEnd="8dp"
|
||||||
android:textAppearance="?attr/textAppearanceBody1"/>
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
|
||||||
|
|
||||||
<TextView
|
<AutoCompleteTextView
|
||||||
android:id="@+id/textViewConvertedScore"
|
android:id="@+id/autoCompleteEquivalentGame"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:hint="Game"
|
||||||
android:gravity="center"
|
android:inputType="none"/>
|
||||||
android:textColor="@color/tetris_navy"
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
android:textAppearance="?attr/textAppearanceHeadline4"
|
|
||||||
tools:text="1,000,000"/>
|
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editTextEquivalentScore"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Score"
|
||||||
|
android:inputType="number"/>
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/buttonAddEquivalent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Add Equivalent Score"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
|
@ -31,6 +31,33 @@
|
||||||
android:inputType="none"/>
|
android:inputType="none"/>
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Progress Chart"
|
||||||
|
android:textAppearance="?attr/textAppearanceSubtitle1" />
|
||||||
|
|
||||||
|
<com.github.mikephil.charting.charts.LineChart
|
||||||
|
android:id="@+id/chartProgress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:layout_marginTop="8dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerViewScores"
|
android:id="@+id/recyclerViewScores"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
53
app/src/main/res/layout/item_equivalent_score.xml
Normal file
53
app/src/main/res/layout/item_equivalent_score.xml
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
app:cardCornerRadius="8dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewGameName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||||
|
android:textColor="@color/tetris_turquoise"
|
||||||
|
tools:text="Tetris DS" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewEquivalentScore"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadline6"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/tetris_navy"
|
||||||
|
tools:text="1,500,000" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewSampleCount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceCaption"
|
||||||
|
android:textStyle="italic"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
tools:text="Based on 3 learning samples" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
|
@ -16,9 +16,4 @@
|
||||||
android:icon="@drawable/ic_stats_24"
|
android:icon="@drawable/ic_stats_24"
|
||||||
android:title="Stats" />
|
android:title="Stats" />
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/navigation_analysis"
|
|
||||||
android:icon="@drawable/ic_analytics_24"
|
|
||||||
android:title="Analysis" />
|
|
||||||
|
|
||||||
</menu>
|
</menu>
|
|
@ -23,10 +23,4 @@
|
||||||
android:label="Statistics"
|
android:label="Statistics"
|
||||||
tools:layout="@layout/fragment_stats" />
|
tools:layout="@layout/fragment_stats" />
|
||||||
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/navigation_analysis"
|
|
||||||
android:name="com.accidentalproductions.tetristats.ui.analysis.ScalingFactorFragment"
|
|
||||||
android:label="Scaling Analysis"
|
|
||||||
tools:layout="@layout/fragment_scaling_factor" />
|
|
||||||
|
|
||||||
</navigation>
|
</navigation>
|
Loading…
Add table
Add a link
Reference in a new issue