From b4d0a3dd80f59ecfe305fa2c166dae863b3577ec Mon Sep 17 00:00:00 2001 From: Corey Date: Mon, 24 Mar 2025 00:17:06 -0400 Subject: [PATCH 1/8] Convert scaling factor analysis to fragment and add to navigation --- .../ui/analysis/ScalingFactorFragment.kt | 99 +++++++++++ .../res/layout/fragment_scaling_factor.xml | 161 ++++++++++++++++++ app/src/main/res/menu/bottom_nav_menu.xml | 5 + .../main/res/navigation/mobile_navigation.xml | 7 + 4 files changed, 272 insertions(+) create mode 100644 app/src/main/java/com/accidentalproductions/tetristats/ui/analysis/ScalingFactorFragment.kt create mode 100644 app/src/main/res/layout/fragment_scaling_factor.xml diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/analysis/ScalingFactorFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/analysis/ScalingFactorFragment.kt new file mode 100644 index 0000000..85cc5e4 --- /dev/null +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/analysis/ScalingFactorFragment.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_scaling_factor.xml b/app/src/main/res/layout/fragment_scaling_factor.xml new file mode 100644 index 0000000..b8e7aab --- /dev/null +++ b/app/src/main/res/layout/fragment_scaling_factor.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml index 0498b82..fc052db 100644 --- a/app/src/main/res/menu/bottom_nav_menu.xml +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -16,4 +16,9 @@ android:icon="@drawable/ic_stats_24" android:title="Stats" /> + + \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index d87901b..84ef1a4 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -22,4 +22,11 @@ android:name="com.accidentalproductions.tetristats.ui.stats.StatsFragment" android:label="Statistics" tools:layout="@layout/fragment_stats" /> + + + \ No newline at end of file From b9c2de15217a50b2110c6918d924b7ec09c9ab7c Mon Sep 17 00:00:00 2001 From: Corey Date: Mon, 24 Mar 2025 00:18:59 -0400 Subject: [PATCH 2/8] Add analytics icon for bottom navigation --- app/src/main/res/drawable/ic_analytics_24.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/src/main/res/drawable/ic_analytics_24.xml diff --git a/app/src/main/res/drawable/ic_analytics_24.xml b/app/src/main/res/drawable/ic_analytics_24.xml new file mode 100644 index 0000000..c523f42 --- /dev/null +++ b/app/src/main/res/drawable/ic_analytics_24.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file From 7fa9e2a12d6675f410de733f295dceb75b789829 Mon Sep 17 00:00:00 2001 From: Corey Date: Mon, 24 Mar 2025 01:45:05 -0400 Subject: [PATCH 3/8] Fix scaling factor generation to properly handle base game conversions --- .../tetristats/util/ScalingFactorAnalyzer.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt b/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt index 2af3d87..dad0a02 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt @@ -96,8 +96,27 @@ class ScalingFactorAnalyzer { 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 = factors[otherGame] ?: RangeScalingFactor(1.0, 1.0, 1.0) + 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}),") } From 857331566eb127ec9e29236e1d7ee123b5ef432b Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 24 Mar 2025 17:01:26 -0400 Subject: [PATCH 4/8] 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. --- app/build.gradle.kts | 3 + .../tetristats/TetriStatsApplication.kt | 7 + .../tetristats/data/ScalingFactorsManager.kt | 166 ++++++++++++++++++ .../tetristats/data/ScoreDao.kt | 3 + .../tetristats/ui/entry/EntryFragment.kt | 164 +++++++++++------ .../tetristats/ui/entry/EntryViewModel.kt | 128 +++++++++++--- .../ui/entry/EquivalentScoreAdapter.kt | 55 ++++++ app/src/main/res/layout/fragment_entry.xml | 123 ++++++------- .../main/res/layout/item_equivalent_score.xml | 53 ++++++ app/src/main/res/menu/bottom_nav_menu.xml | 5 - .../main/res/navigation/mobile_navigation.xml | 6 - 11 files changed, 558 insertions(+), 155 deletions(-) create mode 100644 app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactorsManager.kt create mode 100644 app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EquivalentScoreAdapter.kt create mode 100644 app/src/main/res/layout/item_equivalent_score.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b46fc77..026b6df 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,9 @@ dependencies { implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx: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") androidTestImplementation("androidx.test.ext:junit:1.1.5") diff --git a/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt b/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt index eda4191..f59f7dc 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt @@ -2,11 +2,15 @@ package com.accidentalproductions.tetristats import android.app.Application import androidx.room.Room +import com.accidentalproductions.tetristats.data.ScalingFactorsManager import com.accidentalproductions.tetristats.data.ScoreDatabase class TetriStatsApplication : Application() { lateinit var database: ScoreDatabase private set + + lateinit var scalingFactorsManager: ScalingFactorsManager + private set override fun onCreate() { super.onCreate() @@ -31,5 +35,8 @@ class TetriStatsApplication : Application() { .fallbackToDestructiveMigration() .build() } + + // Initialize the ScalingFactorsManager + scalingFactorsManager = ScalingFactorsManager(applicationContext) } } \ No newline at end of file diff --git a/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactorsManager.kt b/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactorsManager.kt new file mode 100644 index 0000000..e7ee9bb --- /dev/null +++ b/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactorsManager.kt @@ -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> = 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> { + val json = prefs.getString(KEY_LEARNED_FACTORS, null) ?: return mutableMapOf() + val type = object : TypeToken>>() {}.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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt index de27901..416a3d6 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt @@ -15,6 +15,9 @@ interface ScoreDao { @Query("SELECT DISTINCT gameVersion FROM scores") fun getGamesWithScores(): LiveData> + @Query("SELECT COUNT(*) FROM scores") + fun getTotalScoreCount(): LiveData + @Query("SELECT AVG(scoreValue) FROM scores WHERE gameVersion = :gameVersion") fun getAverageScore(gameVersion: String): LiveData diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt index f39d338..338552e 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt @@ -5,8 +5,10 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager import com.accidentalproductions.tetristats.data.Score import com.accidentalproductions.tetristats.databinding.FragmentEntryBinding @@ -14,6 +16,7 @@ class EntryFragment : Fragment() { private var _binding: FragmentEntryBinding? = null private val binding get() = _binding!! private val viewModel: EntryViewModel by viewModels { EntryViewModelFactory(requireActivity().application) } + private lateinit var equivalentScoreAdapter: EquivalentScoreAdapter override fun onCreateView( inflater: LayoutInflater, @@ -28,8 +31,12 @@ class EntryFragment : Fragment() { super.onViewCreated(view, savedInstanceState) setupGameVersionDropdown() - setupScoreConverter() + setupRecyclerView() setupSubmitButton() + setupAutoAnalysis() + + // Check if we should show conversions on startup + viewModel.checkConversionCriteria() } private fun setupGameVersionDropdown() { @@ -45,59 +52,103 @@ class EntryFragment : Fragment() { val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) binding.autoCompleteGameVersion.setAdapter(adapter) } - - private fun setupScoreConverter() { - // Setup "From Game" dropdown with games that have scores - viewModel.gamesWithScores.observe(viewLifecycleOwner) { games -> - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) - binding.autoCompleteFromGame.setAdapter(adapter) - } - - // 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 setupRecyclerView() { + equivalentScoreAdapter = EquivalentScoreAdapter() + binding.recyclerViewEquivalentScores.apply { + adapter = equivalentScoreAdapter + layoutManager = LinearLayoutManager(context) } } - - private fun updateScoreDropdown(gameVersion: String) { - viewModel.getScoresForGame(gameVersion).observe(viewLifecycleOwner) { scores -> - val scoreStrings = scores.map { "${it.scoreValue} (Level ${it.endLevel ?: "?"})"} - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, scoreStrings) - binding.spinnerScoreSelect.setAdapter(adapter) - - binding.spinnerScoreSelect.setOnItemClickListener { _, _, position, _ -> - viewModel.setSelectedScore(scores[position]) + + 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 -> + if (!shouldShow) { + // Hide analysis card if we don't meet criteria + binding.cardAnalysisResults.visibility = View.GONE + + // Also show a message that not enough scores have been entered + if (viewModel.lastSubmittedGame.value != null) { + Toast.makeText( + context, + "Enter at least 3 scores across 2 different games to see conversions", + Toast.LENGTH_LONG + ).show() + } + } + } + + // Only setup equivalence UI when we have scores + viewModel.gamesWithScores.observe(viewLifecycleOwner) { games -> + // Setup the game dropdown for adding equivalents - only with played games + if (games.isNotEmpty()) { + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) + binding.autoCompleteEquivalentGame.setAdapter(adapter) + } + } + + // Update selected game + binding.autoCompleteEquivalentGame.setOnItemClickListener { _, _, _, _ -> + val selectedGame = binding.autoCompleteEquivalentGame.text.toString() + viewModel.setSelectedEquivalentGame(selectedGame) + } + + // Handle adding equivalent scores + 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) { game -> + // Only continue if showConversion is true + if (viewModel.showConversion.value != true) return@observe + + viewModel.lastSubmittedScore.value?.let { score -> + binding.textViewOriginalScore.text = "Your $game score of ${"%,d".format(score)} is equivalent to:" + binding.cardAnalysisResults.visibility = View.VISIBLE + + // 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()) { + 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 + Toast.makeText( + context, + "Add scores for other games to see conversions", + Toast.LENGTH_LONG + ).show() + } + } + } + + // Observe equivalent scores + viewModel.equivalentScores.observe(viewLifecycleOwner) { scores -> + if (scores.isNotEmpty()) { + 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 +170,15 @@ class EntryFragment : Fragment() { linesCleared = linesCleared ) clearInputs() + + // Only scroll down if we're going to show conversions + if (viewModel.showConversion.value == true) { + binding.root.post { + binding.root.fullScroll(View.FOCUS_DOWN) + } + } + } else { + Toast.makeText(context, "Please enter a game and score", Toast.LENGTH_SHORT).show() } } } diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt index 2d1bcae..30fc865 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt @@ -2,48 +2,65 @@ package com.accidentalproductions.tetristats.ui.entry import android.app.Application import androidx.lifecycle.* +import com.accidentalproductions.tetristats.TetriStatsApplication import com.accidentalproductions.tetristats.data.Score import com.accidentalproductions.tetristats.data.ScoreDatabase import com.accidentalproductions.tetristats.data.ScalingFactors +import com.accidentalproductions.tetristats.data.ScalingFactorsManager import kotlinx.coroutines.launch class EntryViewModel(application: Application) : AndroidViewModel(application) { private val database = ScoreDatabase.getDatabase(application) private val scoreDao = database.scoreDao() + private val scalingFactorsManager = (application as TetriStatsApplication).scalingFactorsManager - val gamesWithScores = scoreDao.getGamesWithScores() + // All games list for reference + private val allGames = listOf( + "NES Tetris", + "Game Boy Tetris", + "Tetris DX", + "Tetris DS", + "Tetris Effect", + "Rosy Retrospection DX", + "Apotris" + ) - private val _selectedFromGame = MutableLiveData() - private val _selectedScore = MutableLiveData() - private val _selectedToGame = MutableLiveData() - private val _convertedScore = MutableLiveData() - - val convertedScore: LiveData = _convertedScore + // Track user played games and score counts + val gamesWithScores = scoreDao.getGamesWithScores() + val totalScoreCount = scoreDao.getTotalScoreCount() + + // For auto-analysis + private val _lastSubmittedGame = MutableLiveData() + private val _lastSubmittedScore = MutableLiveData() + private val _equivalentScores = MutableLiveData>() + private val _showConversion = MutableLiveData(false) + + // Current game selection for learning + private val _selectedEquivalentGame = MutableLiveData() + + val equivalentScores: LiveData> = _equivalentScores + val lastSubmittedGame: LiveData = _lastSubmittedGame + val lastSubmittedScore: LiveData = _lastSubmittedScore + val showConversion: LiveData = _showConversion fun getScoresForGame(gameVersion: String): LiveData> { return scoreDao.getScoresForGame(gameVersion) } - - fun setSelectedFromGame(game: String) { - _selectedFromGame.value = game + + fun setSelectedEquivalentGame(game: String) { + _selectedEquivalentGame.value = game } - fun setSelectedScore(score: Score) { - _selectedScore.value = score - } - - fun setSelectedToGame(game: String) { - _selectedToGame.value = game - } - - fun convertScore() { - val fromGame = _selectedFromGame.value - val score = _selectedScore.value - val toGame = _selectedToGame.value - - if (fromGame != null && score != null && toGame != null) { - val convertedScore = ScalingFactors.convertScore(fromGame, toGame, score.scoreValue) - _convertedScore.value = convertedScore + /** + * Check if we should show conversions based on score count and game count + */ + fun checkConversionCriteria() { + viewModelScope.launch { + 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 + _showConversion.postValue(scoreCount >= 3 && gameCount >= 2) } } @@ -63,6 +80,65 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { linesCleared = linesCleared ) scoreDao.insert(newScore) + + // After inserting, update the last submitted values + _lastSubmittedGame.postValue(gameVersion) + _lastSubmittedScore.postValue(score) + + // Check if we should show conversions + checkConversionCriteria() + + // Only generate equivalent scores if we meet the criteria + if (_showConversion.value == true) { + 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() + + // 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 + ) + ) + } + } + + _equivalentScores.postValue(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) + } } } } diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EquivalentScoreAdapter.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EquivalentScoreAdapter.kt new file mode 100644 index 0000000..aa0d780 --- /dev/null +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EquivalentScoreAdapter.kt @@ -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(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() { + 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 +) \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_entry.xml b/app/src/main/res/layout/fragment_entry.xml index f98930d..ec2e0ea 100644 --- a/app/src/main/res/layout/fragment_entry.xml +++ b/app/src/main/res/layout/fragment_entry.xml @@ -112,10 +112,12 @@ - + - + tools:text="Your NES Tetris score of 500,000 is equivalent to:"/> - - - - + android:layout_marginBottom="8dp"/> - - - - + + + + android:text="Do you know the equivalent score in another game? Add it to help improve future conversions:" + android:textAppearance="?attr/textAppearanceBody2" + android:layout_marginBottom="8dp"/> - - - - + android:orientation="horizontal" + android:layout_marginBottom="8dp"> - - - + android:layout_weight="1" + android:layout_marginEnd="8dp" + style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"> - - - + android:hint="Game" + android:inputType="none"/> + - - + + + + + + + diff --git a/app/src/main/res/layout/item_equivalent_score.xml b/app/src/main/res/layout/item_equivalent_score.xml new file mode 100644 index 0000000..0957dc1 --- /dev/null +++ b/app/src/main/res/layout/item_equivalent_score.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml index fc052db..0498b82 100644 --- a/app/src/main/res/menu/bottom_nav_menu.xml +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -16,9 +16,4 @@ android:icon="@drawable/ic_stats_24" android:title="Stats" /> - - \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 84ef1a4..c0de471 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -23,10 +23,4 @@ android:label="Statistics" tools:layout="@layout/fragment_stats" /> - - \ No newline at end of file From f3bfa5650bb5bff07240e6531ba10e014c42dca7 Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 24 Mar 2025 17:06:24 -0400 Subject: [PATCH 5/8] Fix conversion display issues and remove annoying toast notifications --- .../tetristats/ui/entry/EntryFragment.kt | 43 ++++++++----------- .../tetristats/ui/entry/EntryViewModel.kt | 39 +++++++++++++---- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt index 338552e..5e2c8f5 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt @@ -17,6 +17,9 @@ class EntryFragment : Fragment() { private val binding get() = _binding!! 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( inflater: LayoutInflater, @@ -35,8 +38,7 @@ class EntryFragment : Fragment() { setupSubmitButton() setupAutoAnalysis() - // Check if we should show conversions on startup - viewModel.checkConversionCriteria() + // Don't need to check on startup anymore - being handled by ViewModel } private fun setupGameVersionDropdown() { @@ -67,19 +69,7 @@ class EntryFragment : Fragment() { // Observe if we should show conversions viewModel.showConversion.observe(viewLifecycleOwner) { shouldShow -> - if (!shouldShow) { - // Hide analysis card if we don't meet criteria - binding.cardAnalysisResults.visibility = View.GONE - - // Also show a message that not enough scores have been entered - if (viewModel.lastSubmittedGame.value != null) { - Toast.makeText( - context, - "Enter at least 3 scores across 2 different games to see conversions", - Toast.LENGTH_LONG - ).show() - } - } + // No need to show toast here - we'll do it only after score submission } // Only setup equivalence UI when we have scores @@ -112,11 +102,13 @@ class EntryFragment : Fragment() { // Observe last submitted score details viewModel.lastSubmittedGame.observe(viewLifecycleOwner) { game -> // Only continue if showConversion is true - if (viewModel.showConversion.value != true) return@observe + if (viewModel.showConversion.value != true) { + binding.cardAnalysisResults.visibility = View.GONE + return@observe + } viewModel.lastSubmittedScore.value?.let { score -> binding.textViewOriginalScore.text = "Your $game score of ${"%,d".format(score)} is equivalent to:" - binding.cardAnalysisResults.visibility = View.VISIBLE // Get the list of games with scores val playedGames = viewModel.gamesWithScores.value ?: listOf() @@ -124,6 +116,7 @@ class EntryFragment : Fragment() { // Make sure we don't show the source game in the equivalent dropdown val filteredGames = playedGames.filter { it != game } if (filteredGames.isNotEmpty()) { + binding.cardAnalysisResults.visibility = View.VISIBLE val filteredAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, filteredGames) binding.autoCompleteEquivalentGame.setAdapter(filteredAdapter) @@ -133,11 +126,6 @@ class EntryFragment : Fragment() { } else { // If no other games to convert to, hide the card binding.cardAnalysisResults.visibility = View.GONE - Toast.makeText( - context, - "Add scores for other games to see conversions", - Toast.LENGTH_LONG - ).show() } } } @@ -171,8 +159,15 @@ class EntryFragment : Fragment() { ) clearInputs() - // Only scroll down if we're going to show conversions - if (viewModel.showConversion.value == true) { + // Check after submission if we should show requirements toast + 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 { + // Only scroll down if we're going to show conversions binding.root.post { binding.root.fullScroll(View.FOCUS_DOWN) } diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt index 30fc865..13853b7 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt @@ -1,6 +1,7 @@ package com.accidentalproductions.tetristats.ui.entry import android.app.Application +import android.util.Log import androidx.lifecycle.* import com.accidentalproductions.tetristats.TetriStatsApplication import com.accidentalproductions.tetristats.data.Score @@ -42,6 +43,17 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { val lastSubmittedGame: LiveData = _lastSubmittedGame val lastSubmittedScore: LiveData = _lastSubmittedScore val showConversion: LiveData = _showConversion + + init { + // Set up observers to update conversion criteria whenever relevant data changes + gamesWithScores.observeForever { + checkConversionCriteria() + } + + totalScoreCount.observeForever { + checkConversionCriteria() + } + } fun getScoresForGame(gameVersion: String): LiveData> { return scoreDao.getScoresForGame(gameVersion) @@ -55,13 +67,16 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { * Check if we should show conversions based on score count and game count */ fun checkConversionCriteria() { - viewModelScope.launch { - 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 - _showConversion.postValue(scoreCount >= 3 && gameCount >= 2) - } + 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 insertScore( @@ -85,8 +100,7 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { _lastSubmittedGame.postValue(gameVersion) _lastSubmittedScore.postValue(score) - // Check if we should show conversions - checkConversionCriteria() + // The criteria check will happen automatically through the observers in init // Only generate equivalent scores if we meet the criteria if (_showConversion.value == true) { @@ -141,6 +155,13 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { } } } + + 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 { From b7d0382ecc9990335bcb1d1e5b9b3fe6e7e1970c Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 24 Mar 2025 17:11:40 -0400 Subject: [PATCH 6/8] Fix entry view refresh issue to ensure conversion scores are updated when returning to tab --- .../tetristats/ui/entry/EntryFragment.kt | 30 +++++++++++++++++-- .../tetristats/ui/entry/EntryViewModel.kt | 11 +++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt index 5e2c8f5..8c73b79 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt @@ -37,8 +37,27 @@ class EntryFragment : Fragment() { setupRecyclerView() setupSubmitButton() setupAutoAnalysis() - - // Don't need to check on startup anymore - being handled by ViewModel + } + + 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) + } + } } private fun setupGameVersionDropdown() { @@ -70,6 +89,10 @@ class EntryFragment : Fragment() { // 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) { + // Refresh conversions whenever showConversion becomes true + refreshConversions() + } } // Only setup equivalence UI when we have scores @@ -78,6 +101,9 @@ class EntryFragment : Fragment() { if (games.isNotEmpty()) { val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) binding.autoCompleteEquivalentGame.setAdapter(adapter) + + // Also refresh conversions when game list changes + refreshConversions() } } diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt index 13853b7..b4178de 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt @@ -78,6 +78,17 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { _showConversion.postValue(shouldShow) } + + /** + * Force refresh the equivalent scores - use this to ensure UI has latest values + */ + fun refreshEquivalentScores(fromGame: String, score: Int) { + // Only refresh if conversions should be showing + if (_showConversion.value == true) { + Log.d("TetriStats", "Refreshing equivalent scores for $fromGame score $score") + generateEquivalentScores(fromGame, score) + } + } fun insertScore( gameVersion: String, From aeb463fa88c1dd0fb321f47e3d6936ae28213490 Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 24 Mar 2025 17:17:26 -0400 Subject: [PATCH 7/8] Fix auto-refresh issue and add missing games: Modretro Tetris and Tetris Mobile --- .../tetristats/data/ScalingFactors.kt | 48 ++++++-- .../tetristats/ui/entry/EntryFragment.kt | 106 +++++++++++------- .../tetristats/ui/entry/EntryViewModel.kt | 18 +-- 3 files changed, 120 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactors.kt b/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactors.kt index 93030f2..e272e19 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactors.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/data/ScalingFactors.kt @@ -16,7 +16,9 @@ object ScalingFactors { "Tetris DS" to RangeScalingFactor(3.0, 3.3, 4.5), "Tetris Effect" to RangeScalingFactor(2.5, 3.8, 4.5), "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( "NES Tetris" to 1.33, @@ -24,7 +26,9 @@ object ScalingFactors { "Tetris DS" to RangeScalingFactor(4.0, 2.0, 2.0), "Tetris Effect" to RangeScalingFactor(4.0, 2.3, 2.3), "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( "NES Tetris" to 1.33, @@ -32,7 +36,9 @@ object ScalingFactors { "Tetris DS" to RangeScalingFactor(4.0, 2.0, 2.0), "Tetris Effect" to RangeScalingFactor(4.0, 2.3, 2.3), "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( "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 Effect" to RangeScalingFactor(0.83, 0.91, 1.0), "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( "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 DS" to RangeScalingFactor(1.2, 1.1, 1.0), "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( "NES Tetris" to RangeScalingFactor(0.25, 0.67, 0.57), @@ -56,7 +66,9 @@ object ScalingFactors { "Tetris DX" to 0.91, "Tetris DS" to RangeScalingFactor(4.0, 1.5, 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( "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 DS" to RangeScalingFactor(3.0, 1.5, 1.0), "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) ) ) diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt index 8c73b79..deb4109 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt @@ -56,6 +56,50 @@ class EntryFragment : Fragment() { 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 } } } @@ -68,7 +112,9 @@ class EntryFragment : Fragment() { "Tetris DS", "Tetris Effect", "Rosy Retrospection DX", - "Apotris" + "Apotris", + "Modretro Tetris", + "Tetris Mobile" ) val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) binding.autoCompleteGameVersion.setAdapter(adapter) @@ -90,8 +136,10 @@ class EntryFragment : Fragment() { viewModel.showConversion.observe(viewLifecycleOwner) { shouldShow -> // No need to show toast here - we'll do it only after score submission if (shouldShow) { - // Refresh conversions whenever showConversion becomes true - refreshConversions() + // Update card when showConversion changes + updateAnalysisCard() + } else { + binding.cardAnalysisResults.visibility = View.GONE } } @@ -99,11 +147,8 @@ class EntryFragment : Fragment() { viewModel.gamesWithScores.observe(viewLifecycleOwner) { games -> // Setup the game dropdown for adding equivalents - only with played games if (games.isNotEmpty()) { - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) - binding.autoCompleteEquivalentGame.setAdapter(adapter) - - // Also refresh conversions when game list changes - refreshConversions() + // Update card when games list changes + updateAnalysisCard() } } @@ -126,39 +171,22 @@ class EntryFragment : Fragment() { } // Observe last submitted score details - viewModel.lastSubmittedGame.observe(viewLifecycleOwner) { game -> - // Only continue if showConversion is true - if (viewModel.showConversion.value != true) { - binding.cardAnalysisResults.visibility = View.GONE - return@observe - } - - viewModel.lastSubmittedScore.value?.let { score -> - binding.textViewOriginalScore.text = "Your $game score of ${"%,d".format(score)} is equivalent to:" - - // 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.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 - } - } + 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 @@ -185,7 +213,7 @@ class EntryFragment : Fragment() { ) clearInputs() - // Check after submission if we should show requirements toast + // Force immediate refresh of conversions if (viewModel.showConversion.value == false) { Toast.makeText( context, @@ -193,7 +221,9 @@ class EntryFragment : Fragment() { Toast.LENGTH_LONG ).show() } else { - // Only scroll down if we're going to show conversions + refreshConversions() + + // Scroll down to show the analysis results binding.root.post { binding.root.fullScroll(View.FOCUS_DOWN) } diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt index b4178de..62af537 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryViewModel.kt @@ -23,7 +23,9 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { "Tetris DS", "Tetris Effect", "Rosy Retrospection DX", - "Apotris" + "Apotris", + "Modretro Tetris", + "Tetris Mobile" ) // Track user played games and score counts @@ -108,13 +110,14 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { scoreDao.insert(newScore) // After inserting, update the last submitted values - _lastSubmittedGame.postValue(gameVersion) - _lastSubmittedScore.postValue(score) + _lastSubmittedGame.value = gameVersion // Use immediate value change instead of postValue + _lastSubmittedScore.value = score // Use immediate value change instead of postValue - // The criteria check will happen automatically through the observers in init + // Immediately check conversion criteria with current values + checkConversionCriteria() - // Only generate equivalent scores if we meet the criteria - if (_showConversion.value == true) { + // Immediate refresh regardless if we just reached the criteria threshold + if (totalScoreCount.value ?: 0 >= 3 && (gamesWithScores.value?.size ?: 0) >= 2) { generateEquivalentScores(gameVersion, score) } } @@ -146,7 +149,8 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { } } - _equivalentScores.postValue(equivalents) + // Use setValue for immediate update on main thread rather than postValue + _equivalentScores.value = equivalents } /** From 71a2485aace8e1d5efeb77627bb03311123e6305 Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 24 Mar 2025 17:28:48 -0400 Subject: [PATCH 8/8] Add progress chart to statistics page showing score progression over time --- app/build.gradle.kts | 3 + .../tetristats/data/ScoreDao.kt | 3 + .../tetristats/ui/stats/StatsFragment.kt | 87 +++++++++++++++++++ .../tetristats/ui/stats/StatsViewModel.kt | 4 + app/src/main/res/layout/fragment_stats.xml | 27 ++++++ 5 files changed, 124 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 026b6df..bfe3c94 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,9 @@ dependencies { implementation("androidx.navigation:navigation-fragment-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 implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") diff --git a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt index 416a3d6..ecc25c0 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt @@ -12,6 +12,9 @@ interface ScoreDao { @Query("SELECT * FROM scores WHERE gameVersion = :gameVersion") fun getScoresForGame(gameVersion: String): LiveData> + @Query("SELECT * FROM scores WHERE gameVersion = :gameVersion ORDER BY dateRecorded ASC") + fun getScoresForGameByDate(gameVersion: String): LiveData> + @Query("SELECT DISTINCT gameVersion FROM scores") fun getGamesWithScores(): LiveData> diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsFragment.kt index 520981c..94a7097 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsFragment.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsFragment.kt @@ -1,5 +1,6 @@ package com.accidentalproductions.tetristats.ui.stats +import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -8,7 +9,16 @@ import android.widget.ArrayAdapter import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager +import com.accidentalproductions.tetristats.R 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() { private var _binding: FragmentStatsBinding? = null @@ -30,6 +40,7 @@ class StatsFragment : Fragment() { setupRecyclerView() setupGameFilter() + setupProgressChart() observeStats() } @@ -52,6 +63,64 @@ class StatsFragment : Fragment() { viewModel.setSelectedGame(selectedGame) } } + + 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, dates: List) { + 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() { viewModel.filteredScores.observe(viewLifecycleOwner) { scores -> @@ -65,6 +134,24 @@ class StatsFragment : Fragment() { viewModel.highScore.observe(viewLifecycleOwner) { 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() + val dates = mutableListOf() + + 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() { diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsViewModel.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsViewModel.kt index dbd3bd7..c656adb 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsViewModel.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/StatsViewModel.kt @@ -18,6 +18,10 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { scoreDao.getScoresForGame(game) } + val scoresByDate: LiveData> = _selectedGame.switchMap { game -> + scoreDao.getScoresForGameByDate(game) + } + val averageScore: LiveData = _selectedGame.switchMap { game -> scoreDao.getAverageScore(game) } diff --git a/app/src/main/res/layout/fragment_stats.xml b/app/src/main/res/layout/fragment_stats.xml index 426a01d..2cf4a0b 100644 --- a/app/src/main/res/layout/fragment_stats.xml +++ b/app/src/main/res/layout/fragment_stats.xml @@ -31,6 +31,33 @@ android:inputType="none"/> + + + + + + + + + +