diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt index 2686876..4fa6e0a 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -84,6 +84,9 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) private val _activeDownloads = MutableStateFlow>(emptyMap()) val activeDownloads: StateFlow> = _activeDownloads.asStateFlow() + private val _deletingNames = MutableStateFlow>(emptySet()) + val deletingNames: StateFlow> = _deletingNames.asStateFlow() + private fun getDownloadDir(): File { // Public Movies/yaris-obd/ — visible in file managers val dir = File( @@ -189,12 +192,18 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) } fun deleteDownloaded(file: DownloadedFile) { + _deletingNames.value = _deletingNames.value + file.name viewModelScope.launch { - if (file.file.delete()) { - _downloadEvents.emit("✓ Deleted: ${file.name}") + try { + val ok = file.file.delete() + if (ok) { + _downloadEvents.emit("✓ Deleted: ${file.name}") + } else { + _downloadEvents.emit("✗ Could not delete: ${file.name}") + } + } finally { + _deletingNames.value = _deletingNames.value - file.name scanDownloads() - } else { - _downloadEvents.emit("✗ Could not delete: ${file.name}") } } } diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt b/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt index c1b3dc1..b56bfd3 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt @@ -101,6 +101,7 @@ class DownloadWorker( val total = body.contentLength() var downloaded = 0L + var lastProgressMs = 0L body.source().use { source -> localFile.sink().buffer().use { sink -> val buffer = okio.Buffer() @@ -110,9 +111,11 @@ class DownloadWorker( sink.write(buffer, read) downloaded += read - // Update notification + WorkManager progress - if (total > 0) { - val pct = ((downloaded * 100) / total).toInt() + // Throttle progress updates to every 500ms + val now = System.currentTimeMillis() + if (now - lastProgressMs > 500) { + lastProgressMs = now + val pct = if (total > 0) ((downloaded * 100) / total).toInt() else 0 setForeground(createForegroundInfo(filename, pct, downloaded, total)) setProgressAsync(workDataOf("pct" to pct)) } diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt index 77ced33..b85652a 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt @@ -1,10 +1,15 @@ package com.yarisobd.app import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,12 +25,14 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -36,6 +43,7 @@ import androidx.core.content.FileProvider fun DownloadsScreen( files: List, activeDownloads: Map, + deletingNames: Set, onDelete: (DownloadedFile) -> Unit, onCancel: (ActiveDownload) -> Unit, onRefresh: () -> Unit, @@ -43,77 +51,68 @@ fun DownloadsScreen( val context = LocalContext.current Column(modifier = Modifier.fillMaxSize()) { + // Header Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh - ) + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) ) { Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Downloads", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${files.size} files in Movies/yaris-obd/", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text("Downloads", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(4.dp)) + Text("${files.size} files · Movies/yaris-obd/", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - if (activeDownloads.isNotEmpty()) { - LazyColumn( - contentPadding = androidx.compose.foundation.layout.PaddingValues( - start = 16.dp, end = 16.dp - ), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(activeDownloads.entries.toList(), key = { it.key }) { (filename, dl) -> + // Active downloads with progress + AnimatedVisibility(visible = activeDownloads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + activeDownloads.entries.forEach { (filename, dl) -> Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - ) + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f)) ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(14.dp)) { Row( - modifier = Modifier.fillMaxWidth(), + Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = if (dl.done) "✓ $filename" else "⏳ $filename", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + if (dl.done) { + Text("✓", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.width(8.dp)) + Text(filename, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } else { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(10.dp)) + Column { + Text(filename, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${dl.progress}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } if (!dl.done) { Button( onClick = { onCancel(dl) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ), - modifier = Modifier.height(32.dp) - ) { - Text("✕", style = MaterialTheme.typography.bodySmall) - } + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.height(30.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { Text("Cancel", style = MaterialTheme.typography.bodySmall) } } } if (!dl.done) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(Modifier.height(8.dp)) LinearProgressIndicator( progress = { dl.progress / 100f }, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = "${dl.progress}%", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, ) } } @@ -123,73 +122,43 @@ fun DownloadsScreen( } if (files.isEmpty() && activeDownloads.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "No downloaded files yet.\nDownload a video from the Dashcam tab.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("📥", style = MaterialTheme.typography.headlineLarge) + Spacer(Modifier.height(12.dp)) + Text("No downloaded files yet", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Download a video from the Dashcam tab", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } else { - LazyColumn( - contentPadding = androidx.compose.foundation.layout.PaddingValues( - start = 16.dp, end = 16.dp, bottom = 16.dp - ), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { + LazyColumn(contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { items(files, key = { it.name }) { file -> + val isDeleting = file.name in deletingNames Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - // Open with system video player - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file.file - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "video/*") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(intent) - }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh - ) + modifier = Modifier.fillMaxWidth().clickable(enabled = !isDeleting) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file.file) + context.startActivity(Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "video/*"); addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }) + }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), + Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.name, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = formatBytes(file.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column(Modifier.weight(1f)) { + Text(file.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(formatSize(file.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - Button( - onClick = { onDelete(file) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ), - modifier = Modifier.height(36.dp) - ) { - Text("✕", style = MaterialTheme.typography.bodySmall) + if (isDeleting) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) + } else { + Button( + onClick = { onDelete(file) }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { Text("✕", style = MaterialTheme.typography.bodySmall) } } } } @@ -199,11 +168,9 @@ fun DownloadsScreen( } } -private fun formatBytes(bytes: Long): String { - return when { - bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0) - bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) - bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0) - else -> "$bytes B" - } +private fun formatSize(bytes: Long): String = when { + bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0) + else -> "$bytes B" } diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index 60045a9..ec4bb0a 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -55,6 +55,7 @@ fun YarisMainScreen(client: OkHttpClient) { val downloadProgress by dashcamVm.downloadProgress.collectAsState() val downloadedFiles by dashcamVm.downloadedFiles.collectAsState() val activeDownloads by dashcamVm.activeDownloads.collectAsState() + val deletingNames by dashcamVm.deletingNames.collectAsState() val snackbarHostState = remember { SnackbarHostState() } var selectedTab by remember { mutableIntStateOf(0) } @@ -106,6 +107,7 @@ fun YarisMainScreen(client: OkHttpClient) { ) 1 -> DownloadsScreen( files = downloadedFiles, + deletingNames = deletingNames, activeDownloads = activeDownloads, onDelete = { dashcamVm.deleteDownloaded(it) }, onCancel = { dashcamVm.cancelDownload(it) },