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())
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 {
// 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}")
}
}
}
@@ -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))
}
@@ -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<DownloadedFile>,
activeDownloads: Map<String, ActiveDownload>,
deletingNames: Set<String>,
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"
}
@@ -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) },