From 9838378933334915a7061cf187993d4af8b0ed73 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:58:25 +0100 Subject: [PATCH] feat: add logged files viewer in settings --- .../repository/SettingsRepository.kt | 129 +++++++++++++++++ .../ui/settings/LogFilesDialog.kt | 137 ++++++++++++++++++ .../ui/settings/SettingsScreen.kt | 39 +++++ 3 files changed, 305 insertions(+) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt index 8cc4574..45a53d8 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt @@ -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 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 = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return@withContext emptyList() // Legacy not supported for this feature + } + + val files = mutableListOf() + 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 } diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt new file mode 100644 index 0000000..e83406c --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt @@ -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, + 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" + ) + } + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt index 83992d6..874a92a 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt @@ -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>(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 } + ) + } }