Compare commits

..

10 commits

Author SHA1 Message Date
cmclark00
71a2485aac Add progress chart to statistics page showing score progression over time
Some checks failed
Build and Release Android APK / build (push) Has been cancelled
2025-03-24 17:28:48 -04:00
cmclark00
aeb463fa88 Fix auto-refresh issue and add missing games: Modretro Tetris and Tetris Mobile 2025-03-24 17:17:26 -04:00
cmclark00
b7d0382ecc Fix entry view refresh issue to ensure conversion scores are updated when returning to tab 2025-03-24 17:11:40 -04:00
cmclark00
f3bfa5650b Fix conversion display issues and remove annoying toast notifications 2025-03-24 17:06:24 -04:00
cmclark00
857331566e Implement automatic score analysis with dynamic learning. Removed Analysis tab and integrated automatic equivalence calculation after score entry with mandatory thresholds (3+ scores, 2+ games) and showing only played games. 2025-03-24 17:01:26 -04:00
Corey
7fa9e2a12d Fix scaling factor generation to properly handle base game conversions
Some checks are pending
Build and Release Android APK / build (push) Waiting to run
2025-03-24 01:45:05 -04:00
Corey
b9c2de1521 Add analytics icon for bottom navigation
Some checks are pending
Build and Release Android APK / build (push) Waiting to run
2025-03-24 00:18:59 -04:00
Corey
b4d0a3dd80 Convert scaling factor analysis to fragment and add to navigation 2025-03-24 00:17:06 -04:00
Corey
cbeadcafda Add button to access scaling factor analysis tool 2025-03-24 00:11:17 -04:00
Corey
a79783d712 Add scaling factor analysis tool for calculating game score conversions 2025-03-24 00:06:42 -04:00
23 changed files with 1507 additions and 152 deletions

View file

@ -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")

View file

@ -32,6 +32,11 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.ScalingFactorTestActivity"
android:exported="true"
android:label="Scaling Factor Analysis"/>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"

View file

@ -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)
} }
} }

View file

@ -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)
) )
) )

View file

@ -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"
}
}

View file

@ -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>

View file

@ -0,0 +1,83 @@
package com.accidentalproductions.tetristats.ui
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import com.accidentalproductions.tetristats.databinding.ActivityScalingFactorTestBinding
import com.accidentalproductions.tetristats.util.GameScoreSample
import com.accidentalproductions.tetristats.util.ScalingFactorAnalyzer
class ScalingFactorTestActivity : AppCompatActivity() {
private lateinit var binding: ActivityScalingFactorTestBinding
private val analyzer = ScalingFactorAnalyzer()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityScalingFactorTestBinding.inflate(layoutInflater)
setContentView(binding.root)
setupGameDropdown()
setupSkillLevelDropdown()
setupButtons()
}
private fun setupGameDropdown() {
val games = listOf(
"NES Tetris",
"Game Boy Tetris",
"Tetris DX",
"Tetris DS",
"Tetris Effect",
"Rosy Retrospection DX",
"Apotris",
"Modretro Tetris",
"Tetris Mobile"
)
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, games)
binding.spinnerGame.setAdapter(adapter)
}
private fun setupSkillLevelDropdown() {
val skillLevels = listOf("beginner", "intermediate", "advanced")
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, skillLevels)
binding.spinnerSkillLevel.setAdapter(adapter)
}
private fun setupButtons() {
binding.buttonAddSample.setOnClickListener {
val game = binding.spinnerGame.text.toString()
val score = binding.editTextScore.text.toString().toIntOrNull()
val level = binding.editTextLevel.text.toString().toIntOrNull()
val skillLevel = binding.spinnerSkillLevel.text.toString()
val notes = binding.editTextNotes.text.toString()
if (score != null && level != null) {
val sample = GameScoreSample(game, score, level, skillLevel, notes)
analyzer.addSample(sample)
clearInputs()
updateSampleCount()
}
}
binding.buttonAnalyze.setOnClickListener {
analyzer.printAnalysisReport()
binding.textViewReport.text = analyzer.generateScalingFactorCode()
}
binding.buttonClear.setOnClickListener {
analyzer.clearSamples()
updateSampleCount()
binding.textViewReport.text = ""
}
}
private fun updateSampleCount() {
binding.textViewSampleCount.text = "Samples: ${analyzer.sampleCount}"
}
private fun clearInputs() {
binding.editTextScore.text?.clear()
binding.editTextLevel.text?.clear()
binding.editTextNotes.text?.clear()
}
}

View file

@ -0,0 +1,99 @@
package com.accidentalproductions.tetristats.ui.analysis
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import com.accidentalproductions.tetristats.databinding.FragmentScalingFactorBinding
import com.accidentalproductions.tetristats.util.GameScoreSample
import com.accidentalproductions.tetristats.util.ScalingFactorAnalyzer
class ScalingFactorFragment : Fragment() {
private var _binding: FragmentScalingFactorBinding? = null
private val binding get() = _binding!!
private val analyzer = ScalingFactorAnalyzer()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentScalingFactorBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupGameDropdown()
setupSkillLevelDropdown()
setupButtons()
}
private fun setupGameDropdown() {
val games = listOf(
"NES Tetris",
"Game Boy Tetris",
"Tetris DX",
"Tetris DS",
"Tetris Effect",
"Rosy Retrospection DX",
"Apotris",
"Modretro Tetris",
"Tetris Mobile"
)
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games)
binding.spinnerGame.setAdapter(adapter)
}
private fun setupSkillLevelDropdown() {
val skillLevels = listOf("beginner", "intermediate", "advanced")
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, skillLevels)
binding.spinnerSkillLevel.setAdapter(adapter)
}
private fun setupButtons() {
binding.buttonAddSample.setOnClickListener {
val game = binding.spinnerGame.text.toString()
val score = binding.editTextScore.text.toString().toIntOrNull()
val level = binding.editTextLevel.text.toString().toIntOrNull()
val skillLevel = binding.spinnerSkillLevel.text.toString()
val notes = binding.editTextNotes.text.toString()
if (score != null && level != null) {
val sample = GameScoreSample(game, score, level, skillLevel, notes)
analyzer.addSample(sample)
clearInputs()
updateSampleCount()
}
}
binding.buttonAnalyze.setOnClickListener {
analyzer.printAnalysisReport()
binding.textViewReport.text = analyzer.generateScalingFactorCode()
}
binding.buttonClear.setOnClickListener {
analyzer.clearSamples()
updateSampleCount()
binding.textViewReport.text = ""
}
}
private fun updateSampleCount() {
binding.textViewSampleCount.text = "Samples: ${analyzer.sampleCount}"
}
private fun clearInputs() {
binding.editTextScore.text?.clear()
binding.editTextLevel.text?.clear()
binding.editTextNotes.text?.clear()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -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()
} }
} }
} }

View file

@ -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)) {

View file

@ -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
)

View file

@ -1,5 +1,6 @@
package com.accidentalproductions.tetristats.ui.home package com.accidentalproductions.tetristats.ui.home
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -7,7 +8,9 @@ import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.accidentalproductions.tetristats.R
import com.accidentalproductions.tetristats.databinding.FragmentHomeBinding import com.accidentalproductions.tetristats.databinding.FragmentHomeBinding
import com.accidentalproductions.tetristats.ui.ScalingFactorTestActivity
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
@ -32,6 +35,11 @@ class HomeFragment : Fragment() {
homeViewModel.text.observe(viewLifecycleOwner) { homeViewModel.text.observe(viewLifecycleOwner) {
textView.text = it textView.text = it
} }
binding.buttonScalingAnalysis.setOnClickListener {
startActivity(Intent(requireContext(), ScalingFactorTestActivity::class.java))
}
return root return root
} }

View file

@ -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() {

View file

@ -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)
} }

View file

@ -0,0 +1,159 @@
package com.accidentalproductions.tetristats.util
import com.accidentalproductions.tetristats.data.RangeScalingFactor
data class GameScoreSample(
val game: String,
val score: Int,
val level: Int,
val skillLevel: String, // "beginner", "intermediate", "advanced"
val notes: String // Any special conditions or mechanics used
)
class ScalingFactorAnalyzer {
private val samples = mutableListOf<GameScoreSample>()
private val baseGame = "NES Tetris"
val sampleCount: Int
get() = samples.size
fun addSample(sample: GameScoreSample) {
samples.add(sample)
}
fun addSamples(newSamples: List<GameScoreSample>) {
samples.addAll(newSamples)
}
fun clearSamples() {
samples.clear()
}
fun analyzeScoringCurves(): Map<String, RangeScalingFactor> {
val groupedSamples = samples.groupBy { it.game }
val conversionFactors = mutableMapOf<String, RangeScalingFactor>()
groupedSamples.forEach { (game, scores) ->
if (game != baseGame) {
val lowScores = scores.filter { it.score < 100000 }
val midScores = scores.filter { it.score in 100000..500000 }
val highScores = scores.filter { it.score > 500000 }
val lowFactor = calculateAverageFactor(lowScores, baseGame)
val midFactor = calculateAverageFactor(midScores, baseGame)
val highFactor = calculateAverageFactor(highScores, baseGame)
conversionFactors[game] = RangeScalingFactor(lowFactor, midFactor, highFactor)
}
}
return conversionFactors
}
private fun calculateAverageFactor(samples: List<GameScoreSample>, baseGame: String): Double {
if (samples.isEmpty()) return 1.0
val baseGameSamples = this.samples.filter { it.game == baseGame }
if (baseGameSamples.isEmpty()) return 1.0
// Find matching base game samples by skill level
val factors = samples.map { sample ->
val matchingBaseSamples = baseGameSamples.filter {
it.skillLevel == sample.skillLevel &&
it.level == sample.level
}
if (matchingBaseSamples.isNotEmpty()) {
matchingBaseSamples.map { it.score.toDouble() / sample.score }
} else {
// If no exact match, find closest level
val closestBaseSample = baseGameSamples.minByOrNull {
kotlin.math.abs(it.level - sample.level)
}
if (closestBaseSample != null) {
listOf(closestBaseSample.score.toDouble() / sample.score)
} else {
emptyList()
}
}
}.flatten()
return if (factors.isNotEmpty()) {
factors.average()
} else {
1.0
}
}
fun generateScalingFactorCode(): String {
val factors = analyzeScoringCurves()
val code = StringBuilder()
code.appendLine("val FACTORS = mapOf(")
// Add base game entries
val games = samples.map { it.game }.distinct()
games.forEach { game ->
code.appendLine(" \"$game\" to mapOf(")
// Only add factors for games that aren't the current game
games.filter { it != game }.forEach { otherGame ->
val factor = if (game == baseGame) {
// If this is the base game, use the inverse of the other game's factor
factors[otherGame]?.let { RangeScalingFactor(1.0/it.low, 1.0/it.mid, 1.0/it.high) }
?: RangeScalingFactor(1.0, 1.0, 1.0)
} else {
// If this isn't the base game, use the factor relative to the base game
if (otherGame == baseGame) {
factors[game] ?: RangeScalingFactor(1.0, 1.0, 1.0)
} else {
// For non-base game pairs, calculate the relative factor
val baseFactor = factors[game] ?: RangeScalingFactor(1.0, 1.0, 1.0)
val otherFactor = factors[otherGame] ?: RangeScalingFactor(1.0, 1.0, 1.0)
RangeScalingFactor(
baseFactor.low / otherFactor.low,
baseFactor.mid / otherFactor.mid,
baseFactor.high / otherFactor.high
)
}
}
code.appendLine(" \"$otherGame\" to RangeScalingFactor(${factor.low}, ${factor.mid}, ${factor.high}),")
}
code.appendLine(" ),")
}
code.appendLine(")")
return code.toString()
}
fun validateConversion(fromGame: String, toGame: String, score: Int): Double {
val factor = analyzeScoringCurves()[toGame] ?: return 1.0
return when {
score < 100000 -> factor.low
score < 500000 -> factor.mid
else -> factor.high
}
}
fun printAnalysisReport() {
println("=== Scaling Factor Analysis Report ===")
println("Base Game: $baseGame")
println("Total Samples: ${samples.size}")
println("\nSamples per Game:")
samples.groupBy { it.game }.forEach { (game, samples) ->
println("$game: ${samples.size} samples")
println(" Skill Levels: ${samples.map { it.skillLevel }.distinct()}")
println(" Level Range: ${samples.minOf { it.level }} - ${samples.maxOf { it.level }}")
println(" Score Range: ${samples.minOf { it.score }} - ${samples.maxOf { it.score }}")
}
println("\nCalculated Scaling Factors:")
analyzeScoringCurves().forEach { (game, factor) ->
println("$game:")
println(" Low (<100k): ${factor.low}")
println(" Mid (100k-500k): ${factor.mid}")
println(" High (>500k): ${factor.high}")
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM9,17L7,17v-7h2v7zM13,17h-2L11,7h2v10zM17,17h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<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="Scaling Factor Analysis Tool"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="16dp"/>
<TextView
android:id="@+id/textViewSampleCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Samples: 0"
android:layout_marginBottom="16dp"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Game"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_marginBottom="8dp">
<AutoCompleteTextView
android:id="@+id/spinnerGame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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:hint="Skill Level"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_marginBottom="8dp">
<AutoCompleteTextView
android:id="@+id/spinnerSkillLevel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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:hint="Score"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextScore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Level"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextLevel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Notes (optional)"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextNotes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2"/>
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonAddSample"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Add Sample"
android:layout_marginEnd="8dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonClear"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Clear All"
android:layout_marginStart="8dp"/>
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonAnalyze"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Analyze"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Analysis Report"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp"/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/darker_gray"
android:padding="8dp">
<TextView
android:id="@+id/textViewReport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:fontFamily="monospace"/>
</ScrollView>
</LinearLayout>
</ScrollView>

View file

@ -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>

View file

@ -15,8 +15,18 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:textAlignment="center" android:textAlignment="center"
android:textSize="20sp" android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_scaling_analysis"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Scaling Factor Analysis"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/text_home"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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="match_parent">
<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="Scaling Factor Analysis"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="16dp"/>
<TextView
android:id="@+id/textViewSampleCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Samples: 0"
android:layout_marginBottom="16dp"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Game"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_marginBottom="8dp">
<AutoCompleteTextView
android:id="@+id/spinnerGame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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:hint="Skill Level"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_marginBottom="8dp">
<AutoCompleteTextView
android:id="@+id/spinnerSkillLevel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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:hint="Score"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextScore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Level"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextLevel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Notes (optional)"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextNotes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2"/>
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonAddSample"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Add Sample"
android:layout_marginEnd="8dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonClear"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Clear All"
android:layout_marginStart="8dp"/>
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonAnalyze"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Analyze"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Analysis Report"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp"/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/darker_gray"
android:padding="8dp">
<TextView
android:id="@+id/textViewReport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:fontFamily="monospace"/>
</ScrollView>
</LinearLayout>
</ScrollView>

View file

@ -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"

View 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>

View file

@ -22,4 +22,5 @@
android:name="com.accidentalproductions.tetristats.ui.stats.StatsFragment" android:name="com.accidentalproductions.tetristats.ui.stats.StatsFragment"
android:label="Statistics" android:label="Statistics"
tools:layout="@layout/fragment_stats" /> tools:layout="@layout/fragment_stats" />
</navigation> </navigation>