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