UI & feature improvements: Remove Update Stats button, add media attachments to score history, fix layout issues

This commit is contained in:
cmclark00 2025-03-20 13:35:32 -04:00
parent ca8b4fd77b
commit 239aaa5c32
16 changed files with 1028 additions and 142 deletions

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -2,6 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions for media capture and storage -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".TetriStatsApplication"
android:allowBackup="true"
@ -23,6 +31,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -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(

View file

@ -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()
}
}
}

View file

@ -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
)

View file

@ -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
}

View file

@ -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<Intent>
private lateinit var takePhotoLauncher: ActivityResultLauncher<Uri>
private lateinit var takeVideoLauncher: ActivityResultLauncher<Intent>
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<out String>,
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()

View file

@ -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<ExportResult>()
val exportResult: LiveData<ExportResult> = _exportResult
private val _mediaSaveResult = MutableLiveData<MediaSaveResult?>()
val mediaSaveResult: LiveData<MediaSaveResult?> = _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<Application>()
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<Score>): 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<Application>()
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 {

View file

@ -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<Score, ScoreAdapter.ScoreViewHolder>(ScoreDiffCallback()) {
class ScoreAdapter(private val mediaListener: MediaAttachmentListener? = null) :
ListAdapter<Score, ScoreAdapter.ScoreViewHolder>(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)
}
}
}
}
}

View file

@ -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<Score, ScoreAdapter.ScoreViewHolder>(ScoreDiffCallback()) {
@ -23,22 +26,47 @@ class ScoreAdapter : ListAdapter<Score, ScoreAdapter.ScoreViewHolder>(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
}
}
}

View file

@ -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">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
@ -18,11 +17,11 @@
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_nav_menu" />
<fragment
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
app:layout_constraintLeft_toLeftOf="parent"

View file

@ -3,16 +3,51 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="16dp">
android:background="@color/background">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardViewExport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Export Your Scores"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textColor="@color/tetris_navy"
android:layout_marginBottom="8dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonExport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Export to CSV"
app:icon="@android:drawable/ic_menu_share" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewHistory"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@id/cardViewExport" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,110 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:fillViewport="true"
android:background="@color/background">
<com.google.android.material.textfield.TextInputLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
android:orientation="vertical"
android:padding="16dp">
<AutoCompleteTextView
android:id="@+id/autoCompleteGameFilter"
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Filter by Game"
android:inputType="none"/>
</com.google.android.material.textfield.TextInputLayout>
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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewScores"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<AutoCompleteTextView
android:id="@+id/autoCompleteGameFilter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Filter by Game"
android:textColor="@color/tetris_navy"
android:inputType="none"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Game Version">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewScores"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:nestedScrollingEnabled="false"
android:layout_marginBottom="16dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextGameVersion"
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonUpdateStats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Update Stats" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Average Score"
android:textAppearance="?attr/textAppearanceSubtitle1" />
<LinearLayout
<TextView
android:id="@+id/textViewAverageScore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceHeadline4"
tools:text="1000" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Average Score"
android:textAppearance="?attr/textAppearanceSubtitle1" />
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/textViewAverageScore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceHeadline4"
tools:text="1000" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="High Score"
android:textAppearance="?attr/textAppearanceSubtitle1" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/textViewHighScore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceHeadline4"
tools:text="2000" />
</LinearLayout>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="High Score"
android:textAppearance="?attr/textAppearanceSubtitle1" />
<TextView
android:id="@+id/textViewHighScore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceHeadline4"
tools:text="2000" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -14,34 +14,151 @@
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/textViewGameVersion"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textColor="@color/tetris_turquoise"
android:textStyle="bold"
tools:text="Tetris (NES)"/>
<TextView
android:id="@+id/textViewScore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline5"
android:textColor="@color/tetris_navy"
android:textStyle="bold" />
android:textStyle="bold"
tools:text="999,999" />
<TextView
android:id="@+id/textViewDate"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBody2" />
android:orientation="horizontal"
android:layout_marginTop="8dp">
<TextView
android:id="@+id/textViewLevelInfo"
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Date: "
android:textStyle="bold"
android:textAppearance="?attr/textAppearanceBody2" />
<TextView
android:id="@+id/textViewDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody2"
tools:text="03/20/2025" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBody2" />
android:orientation="horizontal"
android:layout_marginTop="4dp">
<TextView
android:id="@+id/textViewLinesCleared"
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Levels: "
android:textStyle="bold"
android:textAppearance="?attr/textAppearanceBody2" />
<TextView
android:id="@+id/textViewLevelInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody2"
tools:text="18 → 29" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutLinesCleared"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBody2" />
android:orientation="horizontal"
android:layout_marginTop="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lines: "
android:textStyle="bold"
android:textAppearance="?attr/textAppearanceBody2" />
<TextView
android:id="@+id/textViewLinesCleared"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody2"
tools:text="230" />
</LinearLayout>
<!-- Media section -->
<FrameLayout
android:id="@+id/mediaContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<!-- Media preview -->
<androidx.cardview.widget.CardView
android:id="@+id/cardViewMedia"
android:layout_width="match_parent"
android:layout_height="180dp"
android:visibility="gone"
app:cardCornerRadius="4dp"
tools:visibility="visible">
<!-- ImageView for photos -->
<ImageView
android:id="@+id/imageViewMedia"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="Score media" />
<!-- Video thumbnail with play indicator -->
<FrameLayout
android:id="@+id/videoContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<VideoView
android:id="@+id/videoViewMedia"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<ImageView
android:id="@+id/imageViewVideoThumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="Video thumbnail" />
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:src="@android:drawable/ic_media_play"
android:alpha="0.7"
android:contentDescription="Play video" />
</FrameLayout>
</androidx.cardview.widget.CardView>
<!-- Add media button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonAddMedia"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add Photo/Video"
app:icon="@android:drawable/ic_menu_camera"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</FrameLayout>
</LinearLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="documents" path="Documents" />
<external-path name="external_files" path="Documents" />
<external-files-path name="tetris_media" path="Pictures/tetris_media" />
<external-files-path name="camera_photos" path="." />
</paths>