diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bfe3c94..b46fc77 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,16 +83,10 @@ 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") 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 820ecc2..5664823 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,11 +32,6 @@ - - > = 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 ecc25c0..de27901 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDao.kt @@ -12,15 +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> - @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/ScalingFactorTestActivity.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/ScalingFactorTestActivity.kt deleted file mode 100644 index 981323c..0000000 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/ScalingFactorTestActivity.kt +++ /dev/null @@ -1,83 +0,0 @@ -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() - } -} \ No newline at end of file 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 deleted file mode 100644 index 85cc5e4..0000000 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/analysis/ScalingFactorFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -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/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt index deb4109..f39d338 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,10 +5,8 @@ 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 @@ -16,10 +14,6 @@ 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 - - // Flag to track if we already showed the requirements toast - private var hasShownRequirementsToast = false override fun onCreateView( inflater: LayoutInflater, @@ -34,74 +28,8 @@ class EntryFragment : Fragment() { super.onViewCreated(view, savedInstanceState) setupGameVersionDropdown() - setupRecyclerView() + setupScoreConverter() 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() { @@ -112,85 +40,64 @@ class EntryFragment : Fragment() { "Tetris DS", "Tetris Effect", "Rosy Retrospection DX", - "Apotris", - "Modretro Tetris", - "Tetris Mobile" + "Apotris" ) val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, games) binding.autoCompleteGameVersion.setAdapter(adapter) } - - private fun setupRecyclerView() { - equivalentScoreAdapter = EquivalentScoreAdapter() - binding.recyclerViewEquivalentScores.apply { - adapter = equivalentScoreAdapter - layoutManager = LinearLayoutManager(context) + + 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 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 -> - // Setup the game dropdown for adding equivalents - only with played games - if (games.isNotEmpty()) { - // Update card when games list changes - updateAnalysisCard() - } - } - - // 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) { _ -> - // 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 + + 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]) } } } @@ -212,24 +119,6 @@ class EntryFragment : Fragment() { linesCleared = linesCleared ) 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() } } } 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 62af537..2d1bcae 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,94 +1,49 @@ 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 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 - // 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 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 - - init { - // Set up observers to update conversion criteria whenever relevant data changes - gamesWithScores.observeForever { - checkConversionCriteria() - } - - totalScoreCount.observeForever { - checkConversionCriteria() - } - } + private val _selectedFromGame = MutableLiveData() + private val _selectedScore = MutableLiveData() + private val _selectedToGame = MutableLiveData() + private val _convertedScore = MutableLiveData() + + val convertedScore: LiveData = _convertedScore fun getScoresForGame(gameVersion: String): LiveData> { return scoreDao.getScoresForGame(gameVersion) } - - fun setSelectedEquivalentGame(game: String) { - _selectedEquivalentGame.value = game + + fun setSelectedFromGame(game: String) { + _selectedFromGame.value = game } - /** - * 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 setSelectedScore(score: Score) { + _selectedScore.value = score } - - /** - * 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 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 } } @@ -108,75 +63,8 @@ class EntryViewModel(application: Application) : AndroidViewModel(application) { linesCleared = linesCleared ) 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() - - // 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 { 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 deleted file mode 100644 index aa0d780..0000000 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EquivalentScoreAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -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/java/com/accidentalproductions/tetristats/ui/home/HomeFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/home/HomeFragment.kt index e01b434..34a7699 100644 --- a/app/src/main/java/com/accidentalproductions/tetristats/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/home/HomeFragment.kt @@ -1,6 +1,5 @@ package com.accidentalproductions.tetristats.ui.home -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -8,9 +7,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.accidentalproductions.tetristats.R import com.accidentalproductions.tetristats.databinding.FragmentHomeBinding -import com.accidentalproductions.tetristats.ui.ScalingFactorTestActivity class HomeFragment : Fragment() { @@ -35,11 +32,6 @@ class HomeFragment : Fragment() { homeViewModel.text.observe(viewLifecycleOwner) { textView.text = it } - - binding.buttonScalingAnalysis.setOnClickListener { - startActivity(Intent(requireContext(), ScalingFactorTestActivity::class.java)) - } - return root } 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 94a7097..520981c 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,6 +1,5 @@ package com.accidentalproductions.tetristats.ui.stats -import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -9,16 +8,7 @@ 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 @@ -40,7 +30,6 @@ class StatsFragment : Fragment() { setupRecyclerView() setupGameFilter() - setupProgressChart() observeStats() } @@ -63,64 +52,6 @@ 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 -> @@ -134,24 +65,6 @@ 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 c656adb..dbd3bd7 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,10 +18,6 @@ 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/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt b/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt deleted file mode 100644 index dad0a02..0000000 --- a/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt +++ /dev/null @@ -1,159 +0,0 @@ -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() - private val baseGame = "NES Tetris" - - val sampleCount: Int - get() = samples.size - - fun addSample(sample: GameScoreSample) { - samples.add(sample) - } - - fun addSamples(newSamples: List) { - samples.addAll(newSamples) - } - - fun clearSamples() { - samples.clear() - } - - fun analyzeScoringCurves(): Map { - val groupedSamples = samples.groupBy { it.game } - val conversionFactors = mutableMapOf() - - 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, 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}") - } - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_analytics_24.xml b/app/src/main/res/drawable/ic_analytics_24.xml deleted file mode 100644 index c523f42..0000000 --- a/app/src/main/res/drawable/ic_analytics_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_scaling_factor_test.xml b/app/src/main/res/layout/activity_scaling_factor_test.xml deleted file mode 100644 index 517160b..0000000 --- a/app/src/main/res/layout/activity_scaling_factor_test.xml +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 ec2e0ea..f98930d 100644 --- a/app/src/main/res/layout/fragment_entry.xml +++ b/app/src/main/res/layout/fragment_entry.xml @@ -112,12 +112,10 @@ - + - + style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"> - - - - - - - - - - + android:hint="From Game" + android:inputType="none"/> + - - + - + android:hint="Select Score" + android:inputType="none"/> + - - - + + + + + android:text="Convert Score" + android:layout_marginBottom="16dp"/> + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 4dad34c..f3d9b08 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -15,18 +15,8 @@ android:layout_marginEnd="8dp" android:textAlignment="center" android:textSize="20sp" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - - \ 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 deleted file mode 100644 index b8e7aab..0000000 --- a/app/src/main/res/layout/fragment_scaling_factor.xml +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_stats.xml b/app/src/main/res/layout/fragment_stats.xml index 2cf4a0b..426a01d 100644 --- a/app/src/main/res/layout/fragment_stats.xml +++ b/app/src/main/res/layout/fragment_stats.xml @@ -31,33 +31,6 @@ android:inputType="none"/> - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 c0de471..d87901b 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -22,5 +22,4 @@ android:name="com.accidentalproductions.tetristats.ui.stats.StatsFragment" android:label="Statistics" tools:layout="@layout/fragment_stats" /> - \ No newline at end of file