diff --git a/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/1.json b/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/1.json
index 6d10b5c..dc15c2f 100644
--- a/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/1.json
+++ b/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/1.json
@@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "9628d8b2a4ce652bff86d922e43b1479",
+ "identityHash": "c9e683b1a15147a0025e3d9b787a4639",
"entities": [
{
"tableName": "scores",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gameVersion` TEXT NOT NULL, `scoreValue` INTEGER NOT NULL, `startLevel` INTEGER, `endLevel` INTEGER, `linesCleared` INTEGER, `dateRecorded` INTEGER NOT NULL)",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gameVersion` TEXT NOT NULL, `scoreValue` INTEGER NOT NULL, `startLevel` INTEGER, `endLevel` INTEGER, `linesCleared` INTEGER, `dateRecorded` INTEGER NOT NULL, `mediaUri` TEXT)",
"fields": [
{
"fieldPath": "id",
@@ -49,6 +49,12 @@
"columnName": "dateRecorded",
"affinity": "INTEGER",
"notNull": true
+ },
+ {
+ "fieldPath": "mediaUri",
+ "columnName": "mediaUri",
+ "affinity": "TEXT",
+ "notNull": false
}
],
"primaryKey": {
@@ -64,7 +70,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9628d8b2a4ce652bff86d922e43b1479')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c9e683b1a15147a0025e3d9b787a4639')"
]
}
}
\ No newline at end of file
diff --git a/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/2.json b/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/2.json
new file mode 100644
index 0000000..f89723f
--- /dev/null
+++ b/app/schemas/com.accidentalproductions.tetristats.data.ScoreDatabase/2.json
@@ -0,0 +1,76 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "c9e683b1a15147a0025e3d9b787a4639",
+ "entities": [
+ {
+ "tableName": "scores",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gameVersion` TEXT NOT NULL, `scoreValue` INTEGER NOT NULL, `startLevel` INTEGER, `endLevel` INTEGER, `linesCleared` INTEGER, `dateRecorded` INTEGER NOT NULL, `mediaUri` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "gameVersion",
+ "columnName": "gameVersion",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scoreValue",
+ "columnName": "scoreValue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startLevel",
+ "columnName": "startLevel",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLevel",
+ "columnName": "endLevel",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "linesCleared",
+ "columnName": "linesCleared",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "dateRecorded",
+ "columnName": "dateRecorded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaUri",
+ "columnName": "mediaUri",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c9e683b1a15147a0025e3d9b787a4639')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a4eadbe..5664823 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/MainActivity.kt b/app/src/main/java/com/accidentalproductions/tetristats/MainActivity.kt
index 140c5ff..2a6b1dd 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/MainActivity.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/MainActivity.kt
@@ -3,7 +3,7 @@ package com.accidentalproductions.tetristats
import android.os.Bundle
import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.appcompat.app.AppCompatActivity
-import androidx.navigation.findNavController
+import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
@@ -21,7 +21,10 @@ class MainActivity : AppCompatActivity() {
val navView: BottomNavigationView = binding.navView
- val navController = findNavController(R.id.nav_host_fragment_activity_main)
+ // Get the NavHostFragment instead of calling findNavController directly
+ val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment
+ val navController = navHostFragment.navController
+
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration(
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt b/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt
index 33da997..eda4191 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/TetriStatsApplication.kt
@@ -10,10 +10,26 @@ class TetriStatsApplication : Application() {
override fun onCreate() {
super.onCreate()
- database = Room.databaseBuilder(
- applicationContext,
- ScoreDatabase::class.java,
- "score_database"
- ).build()
+ try {
+ // First try with migration
+ database = Room.databaseBuilder(
+ applicationContext,
+ ScoreDatabase::class.java,
+ "score_database"
+ )
+ .addMigrations(ScoreDatabase.MIGRATION_1_2)
+ .build()
+ } catch (e: Exception) {
+ // Fallback: If migration fails, recreate the database
+ // This is not ideal for production as it loses data,
+ // but helps during development or if migration fails
+ database = Room.databaseBuilder(
+ applicationContext,
+ ScoreDatabase::class.java,
+ "score_database"
+ )
+ .fallbackToDestructiveMigration()
+ .build()
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/data/Score.kt b/app/src/main/java/com/accidentalproductions/tetristats/data/Score.kt
index f3ca9d8..ba5cc35 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/data/Score.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/data/Score.kt
@@ -12,5 +12,6 @@ data class Score(
val startLevel: Int? = null,
val endLevel: Int? = null,
val linesCleared: Int? = null,
- val dateRecorded: Long = System.currentTimeMillis()
+ val dateRecorded: Long = System.currentTimeMillis(),
+ val mediaUri: String? = null
)
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDatabase.kt b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDatabase.kt
index bb17a9c..8c1b3d9 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDatabase.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/data/ScoreDatabase.kt
@@ -5,15 +5,25 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
import com.accidentalproductions.tetristats.TetriStatsApplication
import com.accidentalproductions.tetristats.util.Converters
-@Database(entities = [Score::class], version = 1)
+@Database(entities = [Score::class], version = 2)
@TypeConverters(Converters::class)
abstract class ScoreDatabase : RoomDatabase() {
abstract fun scoreDao(): ScoreDao
companion object {
+ // Migration from version 1 to 2
+ val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ // Add the mediaUri column to the scores table
+ database.execSQL("ALTER TABLE scores ADD COLUMN mediaUri TEXT")
+ }
+ }
+
fun getDatabase(context: Context): ScoreDatabase {
return (context.applicationContext as TetriStatsApplication).database
}
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryFragment.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryFragment.kt
index ce8a94b..0533a50 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryFragment.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryFragment.kt
@@ -1,20 +1,81 @@
package com.accidentalproductions.tetristats.ui.history
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.provider.MediaStore
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.accidentalproductions.tetristats.databinding.FragmentHistoryBinding
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
-class HistoryFragment : Fragment() {
+class HistoryFragment : Fragment(), ScoreAdapter.MediaAttachmentListener {
private var _binding: FragmentHistoryBinding? = null
private val binding get() = _binding!!
private val viewModel: HistoryViewModel by viewModels { HistoryViewModelFactory(requireActivity().application) }
private lateinit var scoreAdapter: ScoreAdapter
+ // Request code for permissions
+ private val STORAGE_PERMISSION_CODE = 100
+ private val CAMERA_PERMISSION_CODE = 101
+
+ // Current score being edited
+ private var currentScoreId: Long = -1
+ private var currentPhotoUri: Uri? = null
+
+ // Activity Result Launchers
+ private lateinit var pickMediaLauncher: ActivityResultLauncher
+ private lateinit var takePhotoLauncher: ActivityResultLauncher
+ private lateinit var takeVideoLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Initialize activity result launchers
+ pickMediaLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
+ val selectedMediaUri = result.data?.data
+ if (selectedMediaUri != null && currentScoreId != -1L) {
+ // Copy the media to our app's storage for persistence
+ viewModel.saveMediaForScore(currentScoreId, selectedMediaUri)
+ }
+ }
+ }
+
+ takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
+ if (success && currentPhotoUri != null && currentScoreId != -1L) {
+ viewModel.saveMediaForScore(currentScoreId, currentPhotoUri!!)
+ }
+ }
+
+ takeVideoLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
+ val videoUri = result.data?.data
+ if (videoUri != null && currentScoreId != -1L) {
+ viewModel.saveMediaForScore(currentScoreId, videoUri)
+ }
+ }
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -28,22 +89,239 @@ class HistoryFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
+ setupExportButton()
observeScores()
+ observeExportResult()
+ observeMediaSaveResult()
}
private fun setupRecyclerView() {
- scoreAdapter = ScoreAdapter()
+ scoreAdapter = ScoreAdapter(this)
binding.recyclerViewHistory.apply {
adapter = scoreAdapter
layoutManager = LinearLayoutManager(context)
}
}
+
+ private fun setupExportButton() {
+ binding.buttonExport.setOnClickListener {
+ viewModel.exportScoresToCsv()
+ }
+ }
private fun observeScores() {
viewModel.allScores.observe(viewLifecycleOwner) { scores ->
scoreAdapter.submitList(scores)
}
}
+
+ private fun observeExportResult() {
+ viewModel.exportResult.observe(viewLifecycleOwner) { result ->
+ when (result) {
+ is ExportResult.Success -> {
+ shareExportedFile(result.uri)
+ }
+ is ExportResult.Error -> {
+ Toast.makeText(context, result.message, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+
+ private fun observeMediaSaveResult() {
+ viewModel.mediaSaveResult.observe(viewLifecycleOwner) { result ->
+ when (result) {
+ is MediaSaveResult.Success -> {
+ Toast.makeText(context, "Media attached successfully", Toast.LENGTH_SHORT).show()
+ }
+ is MediaSaveResult.Error -> {
+ Toast.makeText(context, "Failed to attach media: ${result.message}", Toast.LENGTH_LONG).show()
+ }
+ null -> { /* Ignore initial state */ }
+ }
+ }
+ }
+
+ private fun shareExportedFile(uri: Uri) {
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/csv"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ val chooserIntent = Intent.createChooser(shareIntent, "Share your Tetris scores")
+ startActivity(chooserIntent)
+ }
+
+ // ScoreAdapter.MediaAttachmentListener implementation
+ override fun onAddMediaClicked(scoreId: Long) {
+ currentScoreId = scoreId
+ showMediaSourceDialog()
+ }
+
+ override fun onMediaClicked(mediaUri: Uri, isVideo: Boolean) {
+ if (isVideo) {
+ // Start video player
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(mediaUri, "video/*")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ if (intent.resolveActivity(requireActivity().packageManager) != null) {
+ startActivity(intent)
+ } else {
+ Toast.makeText(context, "No app available to play video", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ // Show image in full screen
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(mediaUri, "image/*")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ if (intent.resolveActivity(requireActivity().packageManager) != null) {
+ startActivity(intent)
+ } else {
+ Toast.makeText(context, "No app available to view image", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun showMediaSourceDialog() {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle("Add Media")
+ .setItems(arrayOf("Take Photo", "Take Video", "Select from Gallery")) { _, which ->
+ when (which) {
+ 0 -> checkCameraPermissionAndTakePhoto()
+ 1 -> checkCameraPermissionAndTakeVideo()
+ 2 -> checkStoragePermissionAndOpenGallery()
+ }
+ }
+ .show()
+ }
+
+ private fun checkStoragePermissionAndOpenGallery() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Modern Android doesn't need explicit permission for the gallery
+ openGallery()
+ } else {
+ // Check for storage permission on older devices
+ if (ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ openGallery()
+ } else {
+ // Request permission
+ ActivityCompat.requestPermissions(
+ requireActivity(),
+ arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
+ STORAGE_PERMISSION_CODE
+ )
+ }
+ }
+ }
+
+ private fun checkCameraPermissionAndTakePhoto() {
+ if (ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.CAMERA
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ takePhoto()
+ } else {
+ // Request permission
+ ActivityCompat.requestPermissions(
+ requireActivity(),
+ arrayOf(Manifest.permission.CAMERA),
+ CAMERA_PERMISSION_CODE
+ )
+ }
+ }
+
+ private fun checkCameraPermissionAndTakeVideo() {
+ if (ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.CAMERA
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ takeVideo()
+ } else {
+ // Request permission
+ ActivityCompat.requestPermissions(
+ requireActivity(),
+ arrayOf(Manifest.permission.CAMERA),
+ CAMERA_PERMISSION_CODE
+ )
+ }
+ }
+
+ private fun openGallery() {
+ val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI).apply {
+ type = "*/*"
+ putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
+ }
+ pickMediaLauncher.launch(intent)
+ }
+
+ private fun takePhoto() {
+ val photoFile = createImageFile()
+ photoFile?.let {
+ currentPhotoUri = FileProvider.getUriForFile(
+ requireContext(),
+ "${requireContext().packageName}.provider",
+ it
+ )
+ takePhotoLauncher.launch(currentPhotoUri)
+ }
+ }
+
+ private fun takeVideo() {
+ val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)
+ intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1)
+ takeVideoLauncher.launch(intent)
+ }
+
+ private fun createImageFile(): File? {
+ val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val imageFileName = "JPEG_${timeStamp}_"
+ val storageDir = requireContext().getExternalFilesDir(null)
+
+ return try {
+ File.createTempFile(
+ imageFileName,
+ ".jpg",
+ storageDir
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+
+ when (requestCode) {
+ STORAGE_PERMISSION_CODE -> {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ openGallery()
+ } else {
+ Toast.makeText(context, "Storage permission denied", Toast.LENGTH_SHORT).show()
+ }
+ }
+ CAMERA_PERMISSION_CODE -> {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ takePhoto()
+ } else {
+ Toast.makeText(context, "Camera permission denied", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
override fun onDestroyView() {
super.onDestroyView()
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryViewModel.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryViewModel.kt
index 195c528..535777d 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryViewModel.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/history/HistoryViewModel.kt
@@ -1,16 +1,210 @@
package com.accidentalproductions.tetristats.ui.history
import android.app.Application
+import android.content.ContentResolver
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.webkit.MimeTypeMap
+import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.accidentalproductions.tetristats.data.Score
import com.accidentalproductions.tetristats.data.ScoreDatabase
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
class HistoryViewModel(application: Application) : AndroidViewModel(application) {
private val database = ScoreDatabase.getDatabase(application)
private val scoreDao = database.scoreDao()
val allScores = scoreDao.getAllScores()
+
+ private val _exportResult = MutableLiveData()
+ val exportResult: LiveData = _exportResult
+
+ private val _mediaSaveResult = MutableLiveData()
+ val mediaSaveResult: LiveData = _mediaSaveResult
+
+ fun exportScoresToCsv() {
+ viewModelScope.launch {
+ try {
+ val scores = allScores.value ?: emptyList()
+ if (scores.isEmpty()) {
+ _exportResult.postValue(ExportResult.Error("No scores to export"))
+ return@launch
+ }
+
+ val csvContent = generateCsvContent(scores)
+ val uri = saveCsvFile(csvContent)
+ _exportResult.postValue(ExportResult.Success(uri))
+ } catch (e: Exception) {
+ _exportResult.postValue(ExportResult.Error("Export failed: ${e.message}"))
+ }
+ }
+ }
+
+ fun saveMediaForScore(scoreId: Long, mediaUri: Uri) {
+ viewModelScope.launch {
+ try {
+ // Copy the media to our app's internal storage to ensure it's persisted
+ val savedUri = copyMediaToAppStorage(mediaUri)
+
+ // Update the score with the media URI
+ val score = getScoreById(scoreId)
+ if (score != null) {
+ val updatedScore = score.copy(mediaUri = savedUri.toString())
+ updateScore(updatedScore)
+ _mediaSaveResult.postValue(MediaSaveResult.Success)
+ } else {
+ _mediaSaveResult.postValue(MediaSaveResult.Error("Score not found"))
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ _mediaSaveResult.postValue(MediaSaveResult.Error(e.message ?: "Unknown error"))
+ }
+ }
+ }
+
+ private suspend fun getScoreById(scoreId: Long): Score? = withContext(Dispatchers.IO) {
+ val scores = allScores.value ?: emptyList()
+ return@withContext scores.find { it.id == scoreId }
+ }
+
+ private suspend fun updateScore(score: Score) = withContext(Dispatchers.IO) {
+ scoreDao.insert(score) // Using insert with REPLACE strategy
+ }
+
+ private suspend fun copyMediaToAppStorage(sourceUri: Uri): Uri = withContext(Dispatchers.IO) {
+ val context = getApplication()
+ val contentResolver = context.contentResolver
+
+ // Determine file extension
+ val mimeType = contentResolver.getType(sourceUri) ?: "application/octet-stream"
+ val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?:
+ if (mimeType.startsWith("image/")) "jpg" else "mp4"
+
+ // Create a unique filename
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val fileName = "media_${timestamp}_${UUID.randomUUID()}.$extension"
+
+ // Create file in app's private storage
+ val mediaDir = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "tetris_media")
+ if (!mediaDir.exists()) {
+ mediaDir.mkdirs()
+ }
+
+ val destFile = File(mediaDir, fileName)
+
+ // Copy the content
+ contentResolver.openInputStream(sourceUri)?.use { inputStream ->
+ FileOutputStream(destFile).use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ } ?: throw IOException("Failed to open input stream")
+
+ // Return a URI that can be used by our app
+ return@withContext FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ destFile
+ )
+ }
+
+ private fun generateCsvContent(scores: List): String {
+ val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault())
+ val sb = StringBuilder()
+
+ // Header
+ sb.appendLine("ID,Game,Score,Start Level,End Level,Lines Cleared,Date,Media")
+
+ // Data
+ scores.forEach { score ->
+ sb.appendLine(
+ "${score.id}," +
+ "\"${score.gameVersion}\"," +
+ "${score.scoreValue}," +
+ "${score.startLevel ?: ""}," +
+ "${score.endLevel ?: ""}," +
+ "${score.linesCleared ?: ""}," +
+ "${score.dateRecorded.let { dateFormat.format(Date(it)) }}," +
+ "${score.mediaUri ?: ""}"
+ )
+ }
+
+ return sb.toString()
+ }
+
+ private suspend fun saveCsvFile(content: String): Uri = withContext(Dispatchers.IO) {
+ val context = getApplication()
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val fileName = "tetris_scores_$timestamp.csv"
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val contentValues = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+ put(MediaStore.MediaColumns.MIME_TYPE, "text/csv")
+ put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS)
+ }
+
+ val uri = context.contentResolver.insert(
+ MediaStore.Files.getContentUri("external"),
+ contentValues
+ ) ?: throw IOException("Failed to create new MediaStore record")
+
+ context.contentResolver.openOutputStream(uri)?.use { stream ->
+ stream.write(content.toByteArray())
+ } ?: throw IOException("Failed to open output stream")
+
+ return@withContext uri
+ } else {
+ // For older Android versions
+ val documentsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
+ ?: throw IOException("Failed to access Documents directory")
+
+ if (!documentsDir.exists()) {
+ documentsDir.mkdirs()
+ }
+
+ val file = File(documentsDir, fileName)
+ FileOutputStream(file).use { stream ->
+ stream.write(content.toByteArray())
+ }
+
+ return@withContext FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ file
+ )
+ }
+ }
+}
+
+sealed class ExportResult {
+ data class Success(val uri: Uri) : ExportResult()
+ data class Error(val message: String) : ExportResult()
+}
+
+sealed class MediaSaveResult {
+ object Success : MediaSaveResult()
+ data class Error(val message: String) : MediaSaveResult()
}
class HistoryViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/history/ScoreAdapter.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/history/ScoreAdapter.kt
index 4b505fe..7ebac31 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/ui/history/ScoreAdapter.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/history/ScoreAdapter.kt
@@ -1,44 +1,149 @@
package com.accidentalproductions.tetristats.ui.history
+import android.graphics.BitmapFactory
+import android.media.MediaMetadataRetriever
+import android.net.Uri
import android.view.LayoutInflater
+import android.view.View
import android.view.ViewGroup
+import android.widget.MediaController
+import androidx.core.net.toUri
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.accidentalproductions.tetristats.data.Score
import com.accidentalproductions.tetristats.databinding.ItemScoreBinding
+import java.text.NumberFormat
import java.text.SimpleDateFormat
+import java.util.Date
import java.util.Locale
-class ScoreAdapter : ListAdapter(ScoreDiffCallback()) {
+class ScoreAdapter(private val mediaListener: MediaAttachmentListener? = null) :
+ ListAdapter(ScoreDiffCallback()) {
+
+ interface MediaAttachmentListener {
+ fun onAddMediaClicked(scoreId: Long)
+ fun onMediaClicked(mediaUri: Uri, isVideo: Boolean)
+ }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScoreViewHolder {
val binding = ItemScoreBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return ScoreViewHolder(binding)
+ return ScoreViewHolder(binding, mediaListener)
}
override fun onBindViewHolder(holder: ScoreViewHolder, position: Int) {
holder.bind(getItem(position))
}
- class ScoreViewHolder(private val binding: ItemScoreBinding) : RecyclerView.ViewHolder(binding.root) {
+ class ScoreViewHolder(
+ private val binding: ItemScoreBinding,
+ private val mediaListener: MediaAttachmentListener?
+ ) : RecyclerView.ViewHolder(binding.root) {
private val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault())
+ private val numberFormat = NumberFormat.getNumberInstance(Locale.getDefault())
fun bind(score: Score) {
- binding.textViewScore.text = "${score.scoreValue}"
- binding.textViewDate.text = score.dateRecorded?.let { dateFormat.format(it) } ?: "Unknown"
+ // Game version
+ binding.textViewGameVersion.text = score.gameVersion
- val levelInfo = when {
- score.startLevel != null && score.endLevel != null ->
- "Levels ${score.startLevel} → ${score.endLevel}"
- score.endLevel != null ->
- "End Level: ${score.endLevel}"
- else ->
- ""
+ // Score value with formatting
+ binding.textViewScore.text = numberFormat.format(score.scoreValue)
+
+ // Date
+ binding.textViewDate.text = score.dateRecorded.let {
+ dateFormat.format(Date(it))
}
- binding.textViewLevelInfo.text = levelInfo
- binding.textViewLinesCleared.text = score.linesCleared?.let { "Lines: $it" } ?: ""
+ // Level info
+ val hasStartLevel = score.startLevel != null
+ val hasEndLevel = score.endLevel != null
+
+ when {
+ hasStartLevel && hasEndLevel ->
+ binding.textViewLevelInfo.text = "${score.startLevel} → ${score.endLevel}"
+ hasStartLevel && !hasEndLevel ->
+ binding.textViewLevelInfo.text = "${score.startLevel}"
+ !hasStartLevel && hasEndLevel ->
+ binding.textViewLevelInfo.text = "${score.endLevel}"
+ else -> {
+ // Hide the levels section if no level data
+ (binding.textViewLevelInfo.parent as ViewGroup).visibility = View.GONE
+ }
+ }
+
+ // Lines cleared
+ if (score.linesCleared != null) {
+ binding.layoutLinesCleared.visibility = View.VISIBLE
+ binding.textViewLinesCleared.text = "${score.linesCleared}"
+ } else {
+ binding.layoutLinesCleared.visibility = View.GONE
+ }
+
+ // Handle media display
+ setupMedia(score)
+ }
+
+ private fun setupMedia(score: Score) {
+ if (score.mediaUri != null) {
+ // We have media to display
+ binding.cardViewMedia.visibility = View.VISIBLE
+ binding.buttonAddMedia.visibility = View.GONE
+
+ val uri = score.mediaUri.toUri()
+ val isVideo = score.mediaUri.endsWith(".mp4", ignoreCase = true) ||
+ score.mediaUri.endsWith(".3gp", ignoreCase = true) ||
+ score.mediaUri.endsWith(".mkv", ignoreCase = true) ||
+ score.mediaUri.endsWith(".webm", ignoreCase = true)
+
+ if (isVideo) {
+ // Display video thumbnail
+ binding.videoContainer.visibility = View.VISIBLE
+ binding.imageViewMedia.visibility = View.GONE
+
+ try {
+ val retriever = MediaMetadataRetriever()
+ retriever.setDataSource(binding.root.context, uri)
+ val bitmap = retriever.frameAtTime
+ binding.imageViewVideoThumbnail.setImageBitmap(bitmap)
+
+ // Set click listener to play video
+ binding.videoContainer.setOnClickListener {
+ mediaListener?.onMediaClicked(uri, true)
+ }
+
+ retriever.release()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ binding.videoContainer.visibility = View.GONE
+ }
+ } else {
+ // Display image
+ binding.videoContainer.visibility = View.GONE
+ binding.imageViewMedia.visibility = View.VISIBLE
+
+ try {
+ binding.imageViewMedia.setImageURI(uri)
+
+ // Set click listener to view full image
+ binding.imageViewMedia.setOnClickListener {
+ mediaListener?.onMediaClicked(uri, false)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ binding.cardViewMedia.visibility = View.GONE
+ binding.buttonAddMedia.visibility = View.VISIBLE
+ }
+ }
+ } else {
+ // No media, show the add button
+ binding.cardViewMedia.visibility = View.GONE
+ binding.buttonAddMedia.visibility = View.VISIBLE
+
+ // Set click listener to add media
+ binding.buttonAddMedia.setOnClickListener {
+ mediaListener?.onAddMediaClicked(score.id)
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/ScoreAdapter.kt b/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/ScoreAdapter.kt
index 698ca3e..5bcdda5 100644
--- a/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/ScoreAdapter.kt
+++ b/app/src/main/java/com/accidentalproductions/tetristats/ui/stats/ScoreAdapter.kt
@@ -1,13 +1,16 @@
package com.accidentalproductions.tetristats.ui.stats
import android.view.LayoutInflater
+import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.accidentalproductions.tetristats.data.Score
import com.accidentalproductions.tetristats.databinding.ItemScoreBinding
+import java.text.NumberFormat
import java.text.SimpleDateFormat
+import java.util.Date
import java.util.Locale
class ScoreAdapter : ListAdapter(ScoreDiffCallback()) {
@@ -23,22 +26,47 @@ class ScoreAdapter : ListAdapter(ScoreDiffC
class ScoreViewHolder(private val binding: ItemScoreBinding) : RecyclerView.ViewHolder(binding.root) {
private val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault())
+ private val numberFormat = NumberFormat.getNumberInstance(Locale.getDefault())
fun bind(score: Score) {
- binding.textViewScore.text = "${score.scoreValue}"
- binding.textViewDate.text = score.dateRecorded?.let { dateFormat.format(it) } ?: "Unknown"
+ // Game version
+ binding.textViewGameVersion.text = score.gameVersion
- val levelInfo = when {
- score.startLevel != null && score.endLevel != null ->
- "Levels ${score.startLevel} → ${score.endLevel}"
- score.endLevel != null ->
- "End Level: ${score.endLevel}"
- else ->
- ""
+ // Score value with formatting
+ binding.textViewScore.text = numberFormat.format(score.scoreValue)
+
+ // Date
+ binding.textViewDate.text = score.dateRecorded.let {
+ dateFormat.format(Date(it))
}
- binding.textViewLevelInfo.text = levelInfo
- binding.textViewLinesCleared.text = score.linesCleared?.let { "Lines: $it" } ?: ""
+ // Level info
+ val hasStartLevel = score.startLevel != null
+ val hasEndLevel = score.endLevel != null
+
+ when {
+ hasStartLevel && hasEndLevel ->
+ binding.textViewLevelInfo.text = "${score.startLevel} → ${score.endLevel}"
+ hasStartLevel && !hasEndLevel ->
+ binding.textViewLevelInfo.text = "${score.startLevel}"
+ !hasStartLevel && hasEndLevel ->
+ binding.textViewLevelInfo.text = "${score.endLevel}"
+ else -> {
+ // Hide the levels section if no level data
+ (binding.textViewLevelInfo.parent as ViewGroup).visibility = View.GONE
+ }
+ }
+
+ // Lines cleared
+ if (score.linesCleared != null) {
+ binding.layoutLinesCleared.visibility = View.VISIBLE
+ binding.textViewLinesCleared.text = "${score.linesCleared}"
+ } else {
+ binding.layoutLinesCleared.visibility = View.GONE
+ }
+
+ // Always hide media container in stats view
+ binding.mediaContainer.visibility = View.GONE
}
}
}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 06ea6ca..ab3c720 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -3,8 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:paddingTop="?attr/actionBarSize">
+ android:layout_height="match_parent">
-
+ android:background="@color/background">
+
+
+
+
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/cardViewExport" />
\ 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 547976b..426a01d 100644
--- a/app/src/main/res/layout/fragment_stats.xml
+++ b/app/src/main/res/layout/fragment_stats.xml
@@ -1,110 +1,103 @@
-
-
+ android:orientation="vertical"
+ android:padding="16dp">
-
-
+ android:layout_marginBottom="16dp"
+ android:textColorHint="@color/tetris_navy"
+ app:boxStrokeColor="@color/tetris_navy"
+ app:hintTextColor="@color/tetris_navy"
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
-
+
+
-
+
-
-
+ android:layout_marginBottom="16dp"
+ app:cardCornerRadius="8dp"
+ app:cardElevation="4dp">
-
+
-
+
-
+
+
+
+
+
+ android:layout_marginBottom="16dp"
+ app:cardCornerRadius="8dp"
+ app:cardElevation="4dp">
-
+ android:orientation="vertical"
+ android:padding="16dp">
-
-
+
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_score.xml b/app/src/main/res/layout/item_score.xml
index e44db93..0f9074c 100644
--- a/app/src/main/res/layout/item_score.xml
+++ b/app/src/main/res/layout/item_score.xml
@@ -14,34 +14,151 @@
android:orientation="vertical"
android:padding="16dp">
+
+
+ android:textStyle="bold"
+ tools:text="999,999" />
-
+ android:orientation="horizontal"
+ android:layout_marginTop="8dp">
-
+
+
+
+
+
+ android:orientation="horizontal"
+ android:layout_marginTop="4dp">
-
+
+
+
+
+
+ android:orientation="horizontal"
+ android:layout_marginTop="4dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..515a94c
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file