feat: add logged files viewer in settings
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
package com.example.esp32aldldashboard.repository
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
@@ -73,4 +83,123 @@ class SettingsRepository(private val context: Context) {
|
||||
preferences[RECORD_RAW_DATA] = recordRaw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries MediaStore for logged files (CSV and .bin) in Downloads/ALDLLogs
|
||||
*/
|
||||
suspend fun getLoggedFiles(): List<LoggedFile> = withContext(Dispatchers.IO) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return@withContext emptyList() // Legacy not supported for this feature
|
||||
}
|
||||
|
||||
val files = mutableListOf<LoggedFile>()
|
||||
val resolver = context.contentResolver
|
||||
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns._ID,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.SIZE,
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.MediaColumns.MIME_TYPE
|
||||
)
|
||||
|
||||
// Query for CSV files
|
||||
val csvSelection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?"
|
||||
val csvSelectionArgs = arrayOf("%${Environment.DIRECTORY_DOWNLOADS}/ALDLLogs%", "%.csv")
|
||||
|
||||
resolver.query(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
csvSelection,
|
||||
csvSelectionArgs,
|
||||
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||
)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
val dateModified = cursor.getLong(dateColumn)
|
||||
val uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
|
||||
|
||||
files.add(
|
||||
LoggedFile(
|
||||
uri = uri,
|
||||
name = name,
|
||||
size = size,
|
||||
lastModified = dateModified * 1000, // Convert to milliseconds
|
||||
type = FileType.CSV
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for binary files (.bin)
|
||||
val binSelection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?"
|
||||
val binSelectionArgs = arrayOf("%${Environment.DIRECTORY_DOWNLOADS}/ALDLLogs%", "%.bin")
|
||||
|
||||
resolver.query(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
binSelection,
|
||||
binSelectionArgs,
|
||||
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||
)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
val dateModified = cursor.getLong(dateColumn)
|
||||
val uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
|
||||
|
||||
files.add(
|
||||
LoggedFile(
|
||||
uri = uri,
|
||||
name = name,
|
||||
size = size,
|
||||
lastModified = dateModified * 1000,
|
||||
type = FileType.BINARY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
files.sortByDescending { it.lastModified }
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
data class LoggedFile(
|
||||
val uri: Uri,
|
||||
val name: String,
|
||||
val size: Long,
|
||||
val lastModified: Long,
|
||||
val type: FileType
|
||||
) {
|
||||
fun getFormattedSize(): String {
|
||||
return when {
|
||||
size < 1024 -> "$size B"
|
||||
size < 1024 * 1024 -> "${size / 1024} KB"
|
||||
else -> "${size / (1024 * 1024)} MB"
|
||||
}
|
||||
}
|
||||
|
||||
fun getFormattedDate(): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(lastModified))
|
||||
}
|
||||
}
|
||||
|
||||
enum class FileType {
|
||||
CSV, BINARY
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.example.esp32aldldashboard.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.example.esp32aldldashboard.repository.FileType
|
||||
import com.example.esp32aldldashboard.repository.LoggedFile
|
||||
|
||||
@Composable
|
||||
fun LogFilesDialog(
|
||||
files: List<LoggedFile>,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Logged Files (${files.size})") },
|
||||
text = {
|
||||
if (files.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No log files found in Downloads/ALDLLogs",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(files) { file ->
|
||||
FileListItem(
|
||||
file = file,
|
||||
onOpen = {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(file.uri, when(file.type) {
|
||||
FileType.CSV -> "text/csv"
|
||||
FileType.BINARY -> "application/octet-stream"
|
||||
})
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// No app available to open file
|
||||
}
|
||||
},
|
||||
onShare = {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = when(file.type) {
|
||||
FileType.CSV -> "text/csv"
|
||||
FileType.BINARY -> "application/octet-stream"
|
||||
}
|
||||
putExtra(Intent.EXTRA_STREAM, file.uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, file.name)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(shareIntent, "Share ${file.name}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileListItem(
|
||||
file: LoggedFile,
|
||||
onOpen: () -> Unit,
|
||||
onShare: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
onClick = onOpen
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = when(file.type) {
|
||||
FileType.CSV -> MaterialTheme.colorScheme.primary
|
||||
FileType.BINARY -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = "${file.getFormattedDate()} • ${file.getFormattedSize()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onShare) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.example.esp32aldldashboard.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.example.esp32aldldashboard.repository.LoggedFile
|
||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -20,6 +23,9 @@ fun SettingsScreen(
|
||||
val recordRawData by settingsRepository.recordRawDataFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f)
|
||||
|
||||
var showLogFilesDialog by remember { mutableStateOf(false) }
|
||||
var logFiles by remember { mutableStateOf<List<LoggedFile>>(emptyList()) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
||||
@@ -87,6 +93,31 @@ fun SettingsScreen(
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// View Logged Files
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = "View Logged Files", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = "Browse CSV and binary logs", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logFiles = settingsRepository.getLoggedFiles()
|
||||
showLogFilesDialog = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Open")
|
||||
}
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Coolant Alert Threshold
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Text(text = "Coolant Alert Threshold", style = MaterialTheme.typography.titleMedium)
|
||||
@@ -101,4 +132,12 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Log Files Dialog
|
||||
if (showLogFilesDialog) {
|
||||
LogFilesDialog(
|
||||
files = logFiles,
|
||||
onDismiss = { showLogFilesDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user