feat(android): remote portal access — login, stats, trips, dashcam (Phase 28.6)
This commit is contained in:
@@ -17,6 +17,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.DirectionsCar
|
||||
@@ -70,6 +71,9 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
val tripsError by tripVm.error.collectAsState()
|
||||
val route by tripVm.route.collectAsState()
|
||||
val routeLoading by tripVm.routeLoading.collectAsState()
|
||||
val portalVm: PortalViewModel = viewModel()
|
||||
val portalRoute by portalVm.route.collectAsState()
|
||||
val portalRouteLoading by portalVm.routeLoading.collectAsState()
|
||||
val videos by dashcamVm.videos.collectAsState()
|
||||
val totalSize by dashcamVm.totalSize.collectAsState()
|
||||
val retentionDays by dashcamVm.retentionDays.collectAsState()
|
||||
@@ -92,6 +96,7 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var selectedTrip by remember { mutableStateOf<TripSummary?>(null) }
|
||||
var selectedPortalTrip by remember { mutableStateOf<TripSummary?>(null) }
|
||||
|
||||
// Show download/delete events as snackbars
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -110,6 +115,19 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
tripVm.clearRoute()
|
||||
},
|
||||
)
|
||||
} else if (selectedPortalTrip != null) {
|
||||
TripReplayScreen(
|
||||
trip = selectedPortalTrip!!,
|
||||
route = portalRoute,
|
||||
loading = portalRouteLoading,
|
||||
onBack = {
|
||||
selectedPortalTrip = null
|
||||
portalVm.clearRoute()
|
||||
},
|
||||
mapTileUrl = PORTAL_TILE_URL,
|
||||
mapMinZoom = 0.0,
|
||||
mapMaxZoom = 19.0,
|
||||
)
|
||||
} else {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -145,6 +163,12 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
|
||||
label = { Text("Settings") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 5,
|
||||
onClick = { selectedTab = 5 },
|
||||
icon = { Icon(Icons.Filled.Cloud, contentDescription = "Server") },
|
||||
label = { Text("Server") }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
@@ -205,6 +229,13 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
onRefresh = { tripVm.loadTrips() },
|
||||
)
|
||||
4 -> SettingsScreen(client = client)
|
||||
5 -> PortalScreen(
|
||||
viewModel = portalVm,
|
||||
onTripClick = { t ->
|
||||
selectedPortalTrip = t
|
||||
portalVm.loadRoute(t.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yarisobd.app.data.CredentialStore
|
||||
import com.yarisobd.app.data.remote.dto.PortalDashcamFile
|
||||
import com.yarisobd.app.data.remote.dto.PortalStats
|
||||
import com.yarisobd.app.data.remote.dto.TripSummary
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun PortalScreen(
|
||||
viewModel: PortalViewModel,
|
||||
onTripClick: (TripSummary) -> Unit,
|
||||
) {
|
||||
val loggedIn by viewModel.loggedIn.collectAsState()
|
||||
val loggingIn by viewModel.loggingIn.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val trips by viewModel.trips.collectAsState()
|
||||
val tripLabels by viewModel.tripLabels.collectAsState()
|
||||
val dashcam by viewModel.dashcam.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val serverUrl = CredentialStore.getServerUrl(context)
|
||||
|
||||
var username by remember { mutableStateOf(CredentialStore.getPortalUsername(context)) }
|
||||
var password by remember { mutableStateOf(CredentialStore.getPortalPassword(context)) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
com.yarisobd.app.ui.components.YarisHeader()
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
if (loggedIn) "Connected to server" else "Remote server",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (loggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
serverUrl.ifBlank { "not configured" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1.5f)
|
||||
)
|
||||
if (loggedIn) {
|
||||
Button(onClick = { viewModel.logout() }) { Text("Logout") }
|
||||
}
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
LoginCard(
|
||||
username = username,
|
||||
password = password,
|
||||
loggingIn = loggingIn,
|
||||
error = error,
|
||||
onUsernameChange = { username = it },
|
||||
onPasswordChange = { password = it },
|
||||
onLogin = { viewModel.login(username, password) },
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
StatsCard(stats)
|
||||
Text(
|
||||
"Trips (${trips.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (trips.isEmpty()) {
|
||||
Text(
|
||||
"No trips on server yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
trips.forEach { trip ->
|
||||
TripCard(trip, tripLabels[trip.id], onClick = { onTripClick(trip) })
|
||||
}
|
||||
Text(
|
||||
"Dashcam archive (${dashcam.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
dashcam.take(200).forEach { f -> DashcamRow(f) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginCard(
|
||||
username: String,
|
||||
password: String,
|
||||
loggingIn: Boolean,
|
||||
error: String?,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLogin: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Portal sign in", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"View trips and dashcam from anywhere (cellular).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = username, onValueChange = onUsernameChange,
|
||||
label = { Text("Username") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = password, onValueChange = onPasswordChange,
|
||||
label = { Text("Password") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
||||
)
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = onLogin, enabled = !loggingIn, modifier = Modifier.fillMaxWidth()) {
|
||||
if (loggingIn) {
|
||||
CircularProgressIndicator(Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Sign in")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsCard(stats: PortalStats?) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text("Overview", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Stat("Trips", "${stats?.trip_count ?: 0}", Modifier.weight(1f))
|
||||
Stat("Total km", oneDecimal(stats?.total_distance_km), Modifier.weight(1f))
|
||||
Stat("Top speed", "${intText(stats?.top_speed_kmh)} km/h", Modifier.weight(1f))
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Stat("Drive time", durationText(stats?.total_drive_time_s ?: 0), Modifier.weight(1f))
|
||||
Stat("Videos", "${stats?.video_count ?: 0}", Modifier.weight(1f))
|
||||
Stat("Odometer", intText(stats?.odometer_km), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Stat(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashcamRow(f: PortalDashcamFile) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
f.relative_path.substringAfterLast("/"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
formatBytes(f.size_bytes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun intText(v: Double?): String = v?.let { "%.0f".format(Locale.US, it) } ?: "—"
|
||||
private fun oneDecimal(v: Double?): String = v?.let { "%.1f".format(Locale.US, it) } ?: "—"
|
||||
private fun durationText(seconds: Int): String {
|
||||
if (seconds < 60) return "${seconds}s"
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
return if (h > 0) "${h}h ${m}m" else "${m}m"
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String = when {
|
||||
bytes >= 1_000_000_000 -> "%.1f GB".format(Locale.US, bytes / 1_000_000_000.0)
|
||||
bytes >= 1_000_000 -> "%.1f MB".format(Locale.US, bytes / 1_000_000.0)
|
||||
bytes >= 1_000 -> "%.1f KB".format(Locale.US, bytes / 1_000.0)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yarisobd.app.data.CredentialStore
|
||||
import com.yarisobd.app.data.remote.PortalApi
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeCoord
|
||||
import com.yarisobd.app.data.remote.dto.LatLon
|
||||
import com.yarisobd.app.data.remote.dto.PortalDashcamFile
|
||||
import com.yarisobd.app.data.remote.dto.PortalStats
|
||||
import com.yarisobd.app.data.remote.dto.TripRoute
|
||||
import com.yarisobd.app.data.remote.dto.TripSummary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** In-memory cookie jar — keeps the Flask session cookie for the app session. */
|
||||
class SimpleCookieJar : CookieJar {
|
||||
private val store = mutableMapOf<String, MutableList<Cookie>>()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val list = store.getOrPut(url.host) { mutableListOf() }
|
||||
for (c in cookies) {
|
||||
list.removeAll { it.name == c.name }
|
||||
list.add(c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
store[url.host] ?: emptyList()
|
||||
|
||||
fun clear() = store.clear()
|
||||
}
|
||||
|
||||
class PortalViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
|
||||
private val cookieJar = SimpleCookieJar()
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.followRedirects(false)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private var baseUrl: String = ""
|
||||
private var api: PortalApi? = null
|
||||
|
||||
private val _loggedIn = MutableStateFlow(false)
|
||||
val loggedIn: StateFlow<Boolean> = _loggedIn.asStateFlow()
|
||||
|
||||
private val _loggingIn = MutableStateFlow(false)
|
||||
val loggingIn: StateFlow<Boolean> = _loggingIn.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow<PortalStats?>(null)
|
||||
val stats: StateFlow<PortalStats?> = _stats.asStateFlow()
|
||||
|
||||
private val _trips = MutableStateFlow<List<TripSummary>>(emptyList())
|
||||
val trips: StateFlow<List<TripSummary>> = _trips.asStateFlow()
|
||||
|
||||
private val _tripLabels = MutableStateFlow<Map<Int, TripLabels>>(emptyMap())
|
||||
val tripLabels: StateFlow<Map<Int, TripLabels>> = _tripLabels.asStateFlow()
|
||||
|
||||
private val _route = MutableStateFlow<TripRoute?>(null)
|
||||
val route: StateFlow<TripRoute?> = _route.asStateFlow()
|
||||
|
||||
private val _routeLoading = MutableStateFlow(false)
|
||||
val routeLoading: StateFlow<Boolean> = _routeLoading.asStateFlow()
|
||||
|
||||
private val _dashcam = MutableStateFlow<List<PortalDashcamFile>>(emptyList())
|
||||
val dashcam: StateFlow<List<PortalDashcamFile>> = _dashcam.asStateFlow()
|
||||
|
||||
init {
|
||||
val ctx = getApplication<Application>().applicationContext
|
||||
val u = CredentialStore.getPortalUsername(ctx)
|
||||
val p = CredentialStore.getPortalPassword(ctx)
|
||||
if (u.isNotBlank() && p.isNotBlank()) {
|
||||
login(u, p)
|
||||
}
|
||||
}
|
||||
|
||||
fun login(username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_loggingIn.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val ok = doLogin(username, password)
|
||||
if (ok) {
|
||||
CredentialStore.savePortalCredentials(getApplication(), username, password)
|
||||
_loggedIn.value = true
|
||||
loadAll()
|
||||
} else {
|
||||
_error.value = "Login failed — check username/password"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Login failed: ${e.message}"
|
||||
} finally {
|
||||
_loggingIn.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val url = baseUrl.ifBlank { CredentialStore.getServerUrl(getApplication()).trimEnd('/') }
|
||||
okHttp.newCall(Request.Builder().url("$url/logout").get().build()).execute().close()
|
||||
}
|
||||
}
|
||||
cookieJar.clear()
|
||||
_loggedIn.value = false
|
||||
_stats.value = null
|
||||
_trips.value = emptyList()
|
||||
_tripLabels.value = emptyMap()
|
||||
_route.value = null
|
||||
_dashcam.value = emptyList()
|
||||
}
|
||||
|
||||
fun loadStats() {
|
||||
viewModelScope.launch {
|
||||
val a = ensureApi() ?: return@launch
|
||||
try {
|
||||
_stats.value = a.stats()
|
||||
} catch (e: Exception) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTrips() {
|
||||
viewModelScope.launch {
|
||||
val a = ensureApi() ?: return@launch
|
||||
try {
|
||||
val mapped = a.trips().map { it.toTripSummary() }
|
||||
_trips.value = mapped
|
||||
geocode(mapped, a)
|
||||
} catch (e: Exception) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDashcam() {
|
||||
viewModelScope.launch {
|
||||
val a = ensureApi() ?: return@launch
|
||||
try {
|
||||
_dashcam.value = a.dashcam()
|
||||
} catch (e: Exception) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRoute(tripId: Int) {
|
||||
viewModelScope.launch {
|
||||
val a = ensureApi() ?: return@launch
|
||||
_routeLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
_route.value = a.tripRoute(tripId)
|
||||
} catch (e: Exception) {
|
||||
handleError(e)
|
||||
} finally {
|
||||
_routeLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRoute() {
|
||||
_route.value = null
|
||||
}
|
||||
|
||||
private suspend fun loadAll() {
|
||||
loadStats()
|
||||
loadTrips()
|
||||
loadDashcam()
|
||||
}
|
||||
|
||||
/** GET /login for the CSRF token, then POST /login with credentials. */
|
||||
private suspend fun doLogin(username: String, password: String): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = CredentialStore.getServerUrl(getApplication()).trimEnd('/')
|
||||
if (url.isBlank()) return@withContext false
|
||||
baseUrl = url
|
||||
|
||||
val getResp = okHttp.newCall(Request.Builder().url("$url/login").get().build()).execute()
|
||||
getResp.use { resp ->
|
||||
if (resp.code == 302) return@withContext true // already logged in
|
||||
val html = resp.body?.string() ?: return@withContext false
|
||||
val token = Regex("name=\"csrf_token\" value=\"([^\"]+)\"")
|
||||
.find(html)?.groupValues?.get(1) ?: return@withContext false
|
||||
|
||||
val form = FormBody.Builder()
|
||||
.add("csrf_token", token)
|
||||
.add("username", username)
|
||||
.add("password", password)
|
||||
.build()
|
||||
val postResp = okHttp.newCall(Request.Builder().url("$url/login").post(form).build()).execute()
|
||||
postResp.use { pr ->
|
||||
val loc = pr.header("Location") ?: ""
|
||||
pr.code == 302 && !loc.contains("/login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort geocode of trip start/end via the server's batch endpoint. */
|
||||
private suspend fun geocode(trips: List<TripSummary>, a: PortalApi) {
|
||||
val coords = mutableListOf<GeocodeCoord>()
|
||||
for (t in trips) {
|
||||
t.start?.takeIf { it.latitude != null && it.longitude != null }?.let {
|
||||
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
|
||||
}
|
||||
t.end?.takeIf { it.latitude != null && it.longitude != null }?.let {
|
||||
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
|
||||
}
|
||||
}
|
||||
if (coords.isEmpty()) return
|
||||
try {
|
||||
val result = a.geocodeBatch(GeocodeBatchRequest(coords))
|
||||
val labels = mutableMapOf<Int, TripLabels>()
|
||||
for (t in trips) {
|
||||
labels[t.id] = TripLabels(labelFor(result, t.start), labelFor(result, t.end))
|
||||
}
|
||||
_tripLabels.value = labels
|
||||
} catch (_: Exception) {
|
||||
// Geocoding is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
private fun labelFor(result: Map<String, String>, ll: LatLon?): String {
|
||||
val lat = ll?.latitude ?: return "—"
|
||||
val lon = ll?.longitude ?: return "—"
|
||||
val key = String.format(Locale.US, "%.3f,%.3f", lat, lon)
|
||||
return result[key] ?: String.format(Locale.US, "%.4f, %.4f", lat, lon)
|
||||
}
|
||||
|
||||
private fun handleError(e: Exception) {
|
||||
if (e is HttpException && e.code() == 401) {
|
||||
_loggedIn.value = false
|
||||
_error.value = "Session expired — please log in again"
|
||||
} else {
|
||||
_error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureApi(): PortalApi? {
|
||||
val url = CredentialStore.getServerUrl(getApplication()).trimEnd('/')
|
||||
if (url.isBlank()) return null
|
||||
val existing = api
|
||||
if (existing != null && baseUrl == url) return existing
|
||||
baseUrl = url
|
||||
val built = Retrofit.Builder()
|
||||
.baseUrl("$url/")
|
||||
.client(okHttp)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(PortalApi::class.java)
|
||||
api = built
|
||||
return built
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ fun TripListScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) {
|
||||
fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@@ -31,7 +31,9 @@ import org.maplibre.android.style.sources.RasterSource
|
||||
import org.maplibre.android.style.sources.TileSet
|
||||
|
||||
/** Pi serves offline MapTiler raster tiles (zoom 8–15) at this path. */
|
||||
private const val TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png"
|
||||
internal const val PI_TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png"
|
||||
/** Online raster basemap for server trips (works away from the Pi hotspot). */
|
||||
internal const val PORTAL_TILE_URL = "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"
|
||||
|
||||
@Composable
|
||||
fun TripMapView(
|
||||
@@ -40,6 +42,9 @@ fun TripMapView(
|
||||
maxIndex: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onMaxClick: (() -> Unit)? = null,
|
||||
tileUrl: String = PI_TILE_URL,
|
||||
minZoom: Double = 8.0,
|
||||
maxZoom: Double = 15.0,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val mapView = remember { MapView(context) }
|
||||
@@ -61,9 +66,9 @@ fun TripMapView(
|
||||
LaunchedEffect(Unit) {
|
||||
mapView.getMapAsync { map ->
|
||||
mapRef.value = map
|
||||
map.setMinZoomPreference(8.0)
|
||||
map.setMaxZoomPreference(15.0)
|
||||
map.setStyle(buildTripStyle()) { _ ->
|
||||
map.setMinZoomPreference(minZoom)
|
||||
map.setMaxZoomPreference(maxZoom)
|
||||
map.setStyle(buildTripStyle(tileUrl, minZoom, maxZoom)) { _ ->
|
||||
styleReady.value = true
|
||||
}
|
||||
// Tap the amber max-speed marker to jump the timeline to it.
|
||||
@@ -103,10 +108,10 @@ fun TripMapView(
|
||||
AndroidView(factory = { mapView }, modifier = modifier)
|
||||
}
|
||||
|
||||
private fun buildTripStyle(): Style.Builder {
|
||||
val tileSet = TileSet("2.1.0", TILE_URL)
|
||||
tileSet.minZoom = 8f
|
||||
tileSet.maxZoom = 15f
|
||||
private fun buildTripStyle(tileUrl: String, minZoom: Double, maxZoom: Double): Style.Builder {
|
||||
val tileSet = TileSet("2.1.0", tileUrl)
|
||||
tileSet.minZoom = minZoom.toFloat()
|
||||
tileSet.maxZoom = maxZoom.toFloat()
|
||||
tileSet.attribution = "MapTiler"
|
||||
|
||||
val raster = RasterSource("tiles", tileSet, 256)
|
||||
|
||||
@@ -61,6 +61,9 @@ fun TripReplayScreen(
|
||||
route: TripRoute?,
|
||||
loading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
mapTileUrl: String = PI_TILE_URL,
|
||||
mapMinZoom: Double = 8.0,
|
||||
mapMaxZoom: Double = 15.0,
|
||||
) {
|
||||
val points = route?.points ?: emptyList()
|
||||
val maxIndex = remember(points) {
|
||||
@@ -132,6 +135,9 @@ fun TripReplayScreen(
|
||||
playing = false
|
||||
}
|
||||
},
|
||||
tileUrl = mapTileUrl,
|
||||
minZoom = mapMinZoom,
|
||||
maxZoom = mapMaxZoom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ object CredentialStore {
|
||||
private const val KEY_DEVICE_NAME = "device_name"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
private const val KEY_UPLOADED = "uploaded_files"
|
||||
private const val KEY_PORTAL_USERNAME = "portal_username"
|
||||
private const val KEY_PORTAL_PASSWORD = "portal_password"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -35,4 +37,17 @@ object CredentialStore {
|
||||
|
||||
fun getUploaded(context: Context): Set<String> =
|
||||
prefs(context).getStringSet(KEY_UPLOADED, emptySet()) ?: emptySet()
|
||||
|
||||
fun getPortalUsername(context: Context): String =
|
||||
prefs(context).getString(KEY_PORTAL_USERNAME, "") ?: ""
|
||||
|
||||
fun getPortalPassword(context: Context): String =
|
||||
prefs(context).getString(KEY_PORTAL_PASSWORD, "") ?: ""
|
||||
|
||||
fun savePortalCredentials(context: Context, username: String, password: String) {
|
||||
prefs(context).edit()
|
||||
.putString(KEY_PORTAL_USERNAME, username)
|
||||
.putString(KEY_PORTAL_PASSWORD, password)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yarisobd.app.data.remote
|
||||
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeResult
|
||||
import com.yarisobd.app.data.remote.dto.PortalDashcamFile
|
||||
import com.yarisobd.app.data.remote.dto.PortalStats
|
||||
import com.yarisobd.app.data.remote.dto.PortalTripSummary
|
||||
import com.yarisobd.app.data.remote.dto.TripRoute
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Remote server portal API (https://yaris.ceaf.dev) — requires a logged-in
|
||||
* browser session (Flask session cookie), established by PortalViewModel.
|
||||
*/
|
||||
interface PortalApi {
|
||||
|
||||
@GET("/api/v1/portal/stats")
|
||||
suspend fun stats(): PortalStats
|
||||
|
||||
@GET("/api/v1/portal/trips")
|
||||
suspend fun trips(@Query("limit") limit: Int = 200): List<PortalTripSummary>
|
||||
|
||||
@GET("/api/v1/portal/trip/{id}")
|
||||
suspend fun tripRoute(@Path("id") id: Int): TripRoute
|
||||
|
||||
@GET("/api/v1/portal/dashcam")
|
||||
suspend fun dashcam(@Query("limit") limit: Int = 500): List<PortalDashcamFile>
|
||||
|
||||
@GET("/api/geocode")
|
||||
suspend fun geocode(@Query("lat") lat: Double, @Query("lon") lon: Double): GeocodeResult
|
||||
|
||||
@POST("/api/geocode/batch")
|
||||
suspend fun geocodeBatch(@Body body: GeocodeBatchRequest): Map<String, String>
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yarisobd.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One trip from the server's GET /api/v1/portal/trips (flat lat/lon, ISO dates).
|
||||
*/
|
||||
@Serializable
|
||||
data class PortalTripSummary(
|
||||
val id: Int = 0,
|
||||
val start_datetime: String = "",
|
||||
val end_datetime: String = "",
|
||||
val distance_km: Double = 0.0,
|
||||
val duration_seconds: Int = 0,
|
||||
val average_speed_kmh: Double = 0.0,
|
||||
val max_speed_kmh: Double = 0.0,
|
||||
val start_lat: Double? = null,
|
||||
val start_lon: Double? = null,
|
||||
val end_lat: Double? = null,
|
||||
val end_lon: Double? = null,
|
||||
val point_count: Int = 0,
|
||||
val odo_start_km: Double? = null,
|
||||
val odo_end_km: Double? = null,
|
||||
) {
|
||||
/** Map to the shared TripSummary shape so portal trips reuse the Pi UI. */
|
||||
fun toTripSummary(): TripSummary = TripSummary(
|
||||
id = id,
|
||||
start_datetime = start_datetime,
|
||||
end_datetime = end_datetime,
|
||||
start = if (start_lat != null && start_lon != null) LatLon(start_lat, start_lon) else null,
|
||||
end = if (end_lat != null && end_lon != null) LatLon(end_lat, end_lon) else null,
|
||||
distance_km = distance_km,
|
||||
duration_seconds = duration_seconds,
|
||||
point_count = point_count,
|
||||
average_speed_kmh = average_speed_kmh,
|
||||
max_speed_kmh = max_speed_kmh,
|
||||
odo_start_km = odo_start_km,
|
||||
odo_end_km = odo_end_km,
|
||||
)
|
||||
}
|
||||
|
||||
/** Response from the server's GET /api/v1/portal/stats. */
|
||||
@Serializable
|
||||
data class PortalStats(
|
||||
val trip_count: Int = 0,
|
||||
val total_distance_km: Double = 0.0,
|
||||
val total_drive_time_s: Int = 0,
|
||||
val top_speed_kmh: Double = 0.0,
|
||||
val video_count: Int = 0,
|
||||
val odometer_km: Double? = null,
|
||||
)
|
||||
|
||||
/** One file from the server's GET /api/v1/portal/dashcam. */
|
||||
@Serializable
|
||||
data class PortalDashcamFile(
|
||||
val id: Int = 0,
|
||||
val relative_path: String = "",
|
||||
val size_bytes: Long = 0,
|
||||
val uploaded_at: String = "",
|
||||
val storage_path: String = "",
|
||||
)
|
||||
+20
-17
@@ -203,26 +203,29 @@ android/
|
||||
- [x] Trip stats header: distance, duration, avg speed, max speed
|
||||
- [ ] Start/end markers + geocode label below scrubber — deferred
|
||||
|
||||
### 28.6 — Remote portal direct access ⬜ NOT STARTED
|
||||
### 28.6 — Remote portal direct access ✅ DONE (2026-08-15)
|
||||
**Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.**
|
||||
|
||||
#### 28.6.1 — Auth + data layer
|
||||
- [ ] Retrofit interface for server portal:
|
||||
- `POST /login` → session cookie (or token)
|
||||
- `GET /api/v1/portal/stats` → overview stats
|
||||
- `GET /api/v1/portal/trips` → trip list
|
||||
- `GET /api/v1/portal/trip/{id}` → trip route
|
||||
- `GET /api/v1/portal/dashcam` → dashcam file list
|
||||
- `GET /api/v1/portal/dashcam/stream/{id}` → video stream
|
||||
- [ ] Session persistence: store cookie/token in EncryptedSharedPreferences
|
||||
- [ ] Commit: `feat: server portal data layer`
|
||||
#### 28.6.1 — Auth + data layer ✅
|
||||
- [x] `PortalApi` (Retrofit) for the server portal:
|
||||
- `GET /api/v1/portal/stats` → `PortalStats`
|
||||
- `GET /api/v1/portal/trips` → `List<PortalTripSummary>` (mapped to shared `TripSummary`)
|
||||
- `GET /api/v1/portal/trip/{id}` → `TripRoute` (reuses `RoutePoint`)
|
||||
- `GET /api/v1/portal/dashcam` → `List<PortalDashcamFile>`
|
||||
- `GET/POST /api/geocode` + batch (server-side reverse geocoder)
|
||||
- [x] `PortalViewModel` with CSRF form login (`GET /login` → parse token → `POST /login`)
|
||||
+ in-memory `CookieJar` for the Flask session cookie, 401 → auto "session expired"
|
||||
- [x] Portal credentials (username/password) stored in `CredentialStore` (SharedPreferences);
|
||||
auto-login on launch when present
|
||||
- [ ] Dashcam video streaming/player — deferred (list only for now)
|
||||
|
||||
#### 28.6.2 — Portal screens
|
||||
- [ ] Reuse trip list + route replay composables from 28.5 (same data shape)
|
||||
- [ ] Reuse dashcam list + player composables (server serves same JSON shape as Pi)
|
||||
- [ ] Login screen (if not already authenticated)
|
||||
- [ ] "Connected to car" vs "Connected to server" mode indicator
|
||||
- [ ] Commit: `feat: remote portal screens`
|
||||
#### 28.6.2 — Portal screens ✅
|
||||
- [x] New "Server" tab: login form → overview stats + trip list + dashcam archive
|
||||
- [x] Reuses `TripCard` + `TripReplayScreen` from 28.5 (server serves the same route shape)
|
||||
- [x] "Connected to server" indicator + server URL + logout
|
||||
- [x] Tapping a server trip opens the same replay screen with an online CartoDB basemap
|
||||
(`PORTAL_TILE_URL`, zoom 0–19) so it works over cellular away from the hotspot
|
||||
- [ ] Video playback (stream endpoint) — deferred
|
||||
|
||||
### 28.7 — Polish + distribution ⬜ NOT STARTED
|
||||
**Depends on: 28.1–28.6 (whichever subset ships).**
|
||||
|
||||
Reference in New Issue
Block a user