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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -40,6 +41,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.crossfade import coil3.request.crossfade
import com.yarisobd.app.data.remote.dto.DashcamFile
import com.yarisobd.app.data.remote.dto.VideoFile import com.yarisobd.app.data.remote.dto.VideoFile
@Composable @Composable
@@ -50,13 +52,46 @@ fun DashcamScreen(
isLoading: Boolean, isLoading: Boolean,
error: String?, error: String?,
downloadProgress: Map<String, DownloadProgress>, downloadProgress: Map<String, DownloadProgress>,
dashcamFiles: List<DashcamFile>,
dashcamTotalSize: Long,
dashcamStatus: String,
dashcamLoading: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onDownload: (VideoFile) -> Unit, onDownload: (VideoFile) -> Unit,
onDelete: (VideoFile) -> Unit, onDelete: (VideoFile) -> Unit,
onDeleteDashcam: (DashcamFile) -> Unit,
onImport: (List<String>?) -> Unit,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader() 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 // Header card
Card( Card(
modifier = Modifier modifier = Modifier
@@ -10,6 +10,8 @@ import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import com.yarisobd.app.data.CredentialStore import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.PiApi 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.VideoFile
import com.yarisobd.app.data.remote.dto.VideoListResponse import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -92,6 +94,16 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
private val _uploadProgress = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap()) private val _uploadProgress = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap())
val uploadProgress: StateFlow<Map<String, ActiveDownload>> = _uploadProgress.asStateFlow() 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 { 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(
@@ -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) { fun downloadVideo(video: VideoFile) {
val url = "$baseUrl/api/local-video/file?path=${video.path}" val url = "$baseUrl/api/local-video/file?path=${video.path}"
val filename = video.path.substringAfterLast("/") val filename = video.path.substringAfterLast("/")
@@ -62,6 +62,10 @@ fun YarisMainScreen(client: OkHttpClient) {
val activeDownloads by dashcamVm.activeDownloads.collectAsState() val activeDownloads by dashcamVm.activeDownloads.collectAsState()
val deletingNames by dashcamVm.deletingNames.collectAsState() val deletingNames by dashcamVm.deletingNames.collectAsState()
val uploadProgress by dashcamVm.uploadProgress.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 context = androidx.compose.ui.platform.LocalContext.current
val credentialsConfigured = com.yarisobd.app.data.CredentialStore.isConfigured(context) val credentialsConfigured = com.yarisobd.app.data.CredentialStore.isConfigured(context)
@@ -109,9 +113,15 @@ fun YarisMainScreen(client: OkHttpClient) {
isLoading = isLoading, isLoading = isLoading,
error = error, error = error,
downloadProgress = downloadProgress, downloadProgress = downloadProgress,
onRefresh = { dashcamVm.loadVideos() }, dashcamFiles = dashcamFiles,
dashcamTotalSize = dashcamTotalSize,
dashcamStatus = dashcamStatus,
dashcamLoading = dashcamLoading,
onRefresh = { dashcamVm.loadVideos(); dashcamVm.loadDashcamFiles() },
onDownload = { dashcamVm.downloadVideo(it) }, onDownload = { dashcamVm.downloadVideo(it) },
onDelete = { dashcamVm.deleteVideo(it) }, onDelete = { dashcamVm.deleteVideo(it) },
onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) },
onImport = { dashcamVm.importDashcamFiles(it) },
) )
1 -> DownloadsScreen( 1 -> DownloadsScreen(
files = downloadedFiles, files = downloadedFiles,
@@ -1,5 +1,6 @@
package com.yarisobd.app.data.remote 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.GpsStatus
import com.yarisobd.app.data.remote.dto.VideoListResponse import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -7,6 +8,7 @@ import okhttp3.ResponseBody
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Streaming import retrofit2.http.Streaming
@@ -33,6 +35,20 @@ interface PiApi {
suspend fun getThumbnail(@Query("path") path: String): ResponseBody suspend fun getThumbnail(@Query("path") path: String): ResponseBody
/** Delete local copy only */ /** 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) @HTTP(method = "DELETE", path = "/api/local-video", hasBody = true)
suspend fun deleteVideo(@Body body: Map<String, String>): JsonObject 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,
)