feat: Downloads tab UI polish — deleting spinner, progress redesign

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