feat: USB dashcam file browser on Dashcam tab

- PiApi extended: listDashcamFiles, deleteDashcamFile, importDashcamFiles
- DTO: DashcamFilesResponse, DashcamDevice, DashcamFile
- DashcamScreen: USB Dashcam card with status, file count, Import All + Refresh
- ViewModel: loadDashcamFiles, deleteDashcamFile, importDashcamFiles
This commit is contained in:
Mathieu Moran
2026-08-12 05:06:39 -04:00
parent ffa84073ea
commit bc1516257a
5 changed files with 144 additions and 1 deletions
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -40,6 +41,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.yarisobd.app.data.remote.dto.DashcamFile
import com.yarisobd.app.data.remote.dto.VideoFile
@Composable
@@ -50,13 +52,46 @@ fun DashcamScreen(
isLoading: Boolean,
error: String?,
downloadProgress: Map<String, DownloadProgress>,
dashcamFiles: List<DashcamFile>,
dashcamTotalSize: Long,
dashcamStatus: String,
dashcamLoading: Boolean,
onRefresh: () -> Unit,
onDownload: (VideoFile) -> Unit,
onDelete: (VideoFile) -> Unit,
onDeleteDashcam: (DashcamFile) -> Unit,
onImport: (List<String>?) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
// USB Dashcam section
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text("USB Dashcam", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
when (dashcamStatus) {
"ok" -> {
Text("${dashcamFiles.size} files · ${formatBytes(dashcamTotalSize)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (dashcamFiles.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Row {
Button(onClick = { onImport(null) }, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text("Import All", style = MaterialTheme.typography.bodySmall) }
Spacer(Modifier.width(4.dp))
Button(onClick = onRefresh, enabled = !dashcamLoading, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text(if (dashcamLoading) "..." else "Refresh", style = MaterialTheme.typography.bodySmall) }
}
}
}
"no_device" -> Text("No USB dashcam connected", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
"error" -> Text("Failed to read dashcam", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
"" -> Text("Tap Refresh to scan", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
else -> Text(dashcamStatus, style = MaterialTheme.typography.bodySmall)
}
}
}
// Header card
Card(
modifier = Modifier
@@ -10,6 +10,8 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.dto.DashcamFile
import com.yarisobd.app.data.remote.dto.DashcamFilesResponse
import com.yarisobd.app.data.remote.dto.VideoFile
import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -92,6 +94,16 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
private val _uploadProgress = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap())
val uploadProgress: StateFlow<Map<String, ActiveDownload>> = _uploadProgress.asStateFlow()
// USB dashcam state
private val _dashcamFiles = MutableStateFlow<List<DashcamFile>>(emptyList())
val dashcamFiles: StateFlow<List<DashcamFile>> = _dashcamFiles.asStateFlow()
private val _dashcamTotalSize = MutableStateFlow(0L)
val dashcamTotalSize: StateFlow<Long> = _dashcamTotalSize.asStateFlow()
private val _dashcamStatus = MutableStateFlow("")
val dashcamStatus: StateFlow<String> = _dashcamStatus.asStateFlow()
private val _dashcamLoading = MutableStateFlow(false)
val dashcamLoading: StateFlow<Boolean> = _dashcamLoading.asStateFlow()
private fun getDownloadDir(): File {
// Public Movies/yaris-obd/ — visible in file managers
val dir = File(
@@ -124,6 +136,46 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
}
}
fun loadDashcamFiles() {
viewModelScope.launch {
_dashcamLoading.value = true
try {
val resp: DashcamFilesResponse = api.listDashcamFiles()
_dashcamStatus.value = resp.status
_dashcamFiles.value = resp.files
_dashcamTotalSize.value = resp.total_size
} catch (e: Exception) {
_dashcamStatus.value = "error"
_downloadEvents.emit("Dashcam: ${e.message}")
} finally {
_dashcamLoading.value = false
}
}
}
fun deleteDashcamFile(file: DashcamFile) {
viewModelScope.launch {
try {
api.deleteDashcamFile(mapOf("path" to file.path))
_downloadEvents.emit("✓ Deleted from dashcam: ${file.path}")
loadDashcamFiles()
} catch (e: Exception) {
_downloadEvents.emit("✗ Delete failed: ${e.message}")
}
}
}
fun importDashcamFiles(paths: List<String>?) {
viewModelScope.launch {
try {
api.importDashcamFiles(mapOf("paths" to (paths ?: emptyList())))
_downloadEvents.emit("⏳ Import started")
} catch (e: Exception) {
_downloadEvents.emit("✗ Import failed: ${e.message}")
}
}
}
fun downloadVideo(video: VideoFile) {
val url = "$baseUrl/api/local-video/file?path=${video.path}"
val filename = video.path.substringAfterLast("/")
@@ -62,6 +62,10 @@ fun YarisMainScreen(client: OkHttpClient) {
val activeDownloads by dashcamVm.activeDownloads.collectAsState()
val deletingNames by dashcamVm.deletingNames.collectAsState()
val uploadProgress by dashcamVm.uploadProgress.collectAsState()
val dashcamFiles by dashcamVm.dashcamFiles.collectAsState()
val dashcamTotalSize by dashcamVm.dashcamTotalSize.collectAsState()
val dashcamStatus by dashcamVm.dashcamStatus.collectAsState()
val dashcamLoading by dashcamVm.dashcamLoading.collectAsState()
val context = androidx.compose.ui.platform.LocalContext.current
val credentialsConfigured = com.yarisobd.app.data.CredentialStore.isConfigured(context)
@@ -109,9 +113,15 @@ fun YarisMainScreen(client: OkHttpClient) {
isLoading = isLoading,
error = error,
downloadProgress = downloadProgress,
onRefresh = { dashcamVm.loadVideos() },
dashcamFiles = dashcamFiles,
dashcamTotalSize = dashcamTotalSize,
dashcamStatus = dashcamStatus,
dashcamLoading = dashcamLoading,
onRefresh = { dashcamVm.loadVideos(); dashcamVm.loadDashcamFiles() },
onDownload = { dashcamVm.downloadVideo(it) },
onDelete = { dashcamVm.deleteVideo(it) },
onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) },
onImport = { dashcamVm.importDashcamFiles(it) },
)
1 -> DownloadsScreen(
files = downloadedFiles,
@@ -1,5 +1,6 @@
package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.DashcamFilesResponse
import com.yarisobd.app.data.remote.dto.GpsStatus
import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.serialization.json.JsonObject
@@ -7,6 +8,7 @@ import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Streaming
@@ -33,6 +35,20 @@ interface PiApi {
suspend fun getThumbnail(@Query("path") path: String): ResponseBody
/** Delete local copy only */
// --- USB dashcam endpoints ---
/** List files on USB dashcam (Phase 16) */
@GET("/api/dash/files")
suspend fun listDashcamFiles(): DashcamFilesResponse
/** Delete a single file from USB dashcam */
@HTTP(method = "DELETE", path = "/api/dash/file", hasBody = true)
suspend fun deleteDashcamFile(@Body body: Map<String, String>): JsonObject
/** Import files from USB dashcam (selected or all) */
@POST("/api/dash/import")
suspend fun importDashcamFiles(@Body body: Map<String, List<String>>): JsonObject
@HTTP(method = "DELETE", path = "/api/local-video", hasBody = true)
suspend fun deleteVideo(@Body body: Map<String, String>): JsonObject
}
@@ -0,0 +1,30 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class DashcamFilesResponse(
val status: String = "",
val message: String? = null,
val device: DashcamDevice? = null,
val files: List<DashcamFile> = emptyList(),
val count: Int = 0,
val total_size: Long = 0,
val errors: List<String> = emptyList(),
)
@Serializable
data class DashcamDevice(
val path: String = "",
val fstype: String = "",
val label: String? = null,
val size: String? = null,
val mountpoint: String? = null,
)
@Serializable
data class DashcamFile(
val path: String = "",
val size: Long = 0,
val modified: Double = 0.0,
)