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