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:
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user