diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b46fc77..bfe3c94 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -83,10 +83,16 @@ 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 5664823..820ecc2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -32,6 +32,11 @@
+
+
> = 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..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,9 +12,15 @@ 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
new file mode 100644
index 0000000..981323c
--- /dev/null
+++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/ScalingFactorTestActivity.kt
@@ -0,0 +1,83 @@
+package com.accidentalproductions.tetristats.ui
+
+import android.os.Bundle
+import android.widget.ArrayAdapter
+import androidx.appcompat.app.AppCompatActivity
+import com.accidentalproductions.tetristats.databinding.ActivityScalingFactorTestBinding
+import com.accidentalproductions.tetristats.util.GameScoreSample
+import com.accidentalproductions.tetristats.util.ScalingFactorAnalyzer
+
+class ScalingFactorTestActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityScalingFactorTestBinding
+ private val analyzer = ScalingFactorAnalyzer()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityScalingFactorTestBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ setupGameDropdown()
+ setupSkillLevelDropdown()
+ setupButtons()
+ }
+
+ private fun setupGameDropdown() {
+ val games = listOf(
+ "NES Tetris",
+ "Game Boy Tetris",
+ "Tetris DX",
+ "Tetris DS",
+ "Tetris Effect",
+ "Rosy Retrospection DX",
+ "Apotris",
+ "Modretro Tetris",
+ "Tetris Mobile"
+ )
+ val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, games)
+ binding.spinnerGame.setAdapter(adapter)
+ }
+
+ private fun setupSkillLevelDropdown() {
+ val skillLevels = listOf("beginner", "intermediate", "advanced")
+ val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, skillLevels)
+ binding.spinnerSkillLevel.setAdapter(adapter)
+ }
+
+ private fun setupButtons() {
+ binding.buttonAddSample.setOnClickListener {
+ val game = binding.spinnerGame.text.toString()
+ val score = binding.editTextScore.text.toString().toIntOrNull()
+ val level = binding.editTextLevel.text.toString().toIntOrNull()
+ val skillLevel = binding.spinnerSkillLevel.text.toString()
+ val notes = binding.editTextNotes.text.toString()
+
+ if (score != null && level != null) {
+ val sample = GameScoreSample(game, score, level, skillLevel, notes)
+ analyzer.addSample(sample)
+ clearInputs()
+ updateSampleCount()
+ }
+ }
+
+ binding.buttonAnalyze.setOnClickListener {
+ analyzer.printAnalysisReport()
+ binding.textViewReport.text = analyzer.generateScalingFactorCode()
+ }
+
+ binding.buttonClear.setOnClickListener {
+ analyzer.clearSamples()
+ updateSampleCount()
+ binding.textViewReport.text = ""
+ }
+ }
+
+ private fun updateSampleCount() {
+ binding.textViewSampleCount.text = "Samples: ${analyzer.sampleCount}"
+ }
+
+ private fun clearInputs() {
+ binding.editTextScore.text?.clear()
+ binding.editTextLevel.text?.clear()
+ binding.editTextNotes.text?.clear()
+ }
+}
\ 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
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/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/entry/EntryFragment.kt
index f39d338..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
@@ -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,10 @@ 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,
@@ -28,8 +34,74 @@ class EntryFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
setupGameVersionDropdown()
- setupScoreConverter()
+ setupRecyclerView()
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() {
@@ -40,64 +112,85 @@ 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)
}
-
- 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 ->
+ // 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
}
}
}
@@ -119,6 +212,24 @@ 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 2d1bcae..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
@@ -1,49 +1,94 @@
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
- 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",
+ "Modretro Tetris",
+ "Tetris Mobile"
+ )
- 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
+
+ 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)
}
-
- fun setSelectedFromGame(game: String) {
- _selectedFromGame.value = game
+
+ fun setSelectedEquivalentGame(game: String) {
+ _selectedEquivalentGame.value = game
}
- fun setSelectedScore(score: Score) {
- _selectedScore.value = score
+ /**
+ * Check if we should show conversions based on score count and game count
+ */
+ fun checkConversionCriteria() {
+ val scoreCount = totalScoreCount.value ?: 0
+ val gameCount = gamesWithScores.value?.size ?: 0
+
+ // Only show conversions if there are at least 3 scores across at least 2 games
+ val shouldShow = scoreCount >= 3 && gameCount >= 2
+
+ // For debugging
+ Log.d("TetriStats", "Checking conversion criteria: scores=$scoreCount, games=$gameCount, shouldShow=$shouldShow")
+
+ _showConversion.postValue(shouldShow)
}
-
- fun setSelectedToGame(game: String) {
- _selectedToGame.value = game
- }
-
- 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
+
+ /**
+ * 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)
}
}
@@ -63,8 +108,75 @@ 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
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/java/com/accidentalproductions/tetristats/ui/home/HomeFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/home/HomeFragment.kt
index 34a7699..e01b434 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,5 +1,6 @@
package com.accidentalproductions.tetristats.ui.home
+import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -7,7 +8,9 @@ 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() {
@@ -32,6 +35,11 @@ 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 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/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt b/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt
new file mode 100644
index 0000000..dad0a02
--- /dev/null
+++ b/app/src/main/java/com/accidentalproductions/tetristats/util/ScalingFactorAnalyzer.kt
@@ -0,0 +1,159 @@
+package com.accidentalproductions.tetristats.util
+
+import com.accidentalproductions.tetristats.data.RangeScalingFactor
+
+data class GameScoreSample(
+ val game: String,
+ val score: Int,
+ val level: Int,
+ val skillLevel: String, // "beginner", "intermediate", "advanced"
+ val notes: String // Any special conditions or mechanics used
+)
+
+class ScalingFactorAnalyzer {
+ private val samples = mutableListOf()
+ 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
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
diff --git a/app/src/main/res/layout/activity_scaling_factor_test.xml b/app/src/main/res/layout/activity_scaling_factor_test.xml
new file mode 100644
index 0000000..517160b
--- /dev/null
+++ b/app/src/main/res/layout/activity_scaling_factor_test.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
index f3d9b08..4dad34c 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -15,8 +15,18 @@
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
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/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"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..c0de471 100644
--- a/app/src/main/res/navigation/mobile_navigation.xml
+++ b/app/src/main/res/navigation/mobile_navigation.xml
@@ -22,4 +22,5 @@
android:name="com.accidentalproductions.tetristats.ui.stats.StatsFragment"
android:label="Statistics"
tools:layout="@layout/fragment_stats" />
+
\ No newline at end of file