feat: add drive detail screen with map and improve dashboard

- Add DriveDetailScreen with OpenStreetMap route visualization
- Show drive statistics: speed, power, battery, elevation, temperature
- Add interactive charts for speed, power, battery, and elevation profiles
- Fix DriveModels to match API response structure (drive_details field)
- Add small location map to dashboard Location card
- Make dashboard quick link cards fill horizontal space equally
- Add osmdroid dependency for OpenStreetMap integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Davide Ferrari
2025-12-20 15:49:13 +01:00
parent af39ff39c8
commit fa7fbaa910
9 changed files with 1044 additions and 11 deletions

View File

@@ -92,6 +92,9 @@ dependencies {
// Charts
implementation(libs.vico.compose.m3)
// Maps
implementation(libs.osmdroid)
// Testing
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)

View File

@@ -75,7 +75,19 @@ data class DriveRange(
@JsonClass(generateAdapter = true)
data class DriveDetailResponse(
@Json(name = "data") val data: DriveDetail? = null
@Json(name = "data") val data: DriveDetailData? = null
)
@JsonClass(generateAdapter = true)
data class DriveDetailData(
@Json(name = "car") val car: DriveDetailCar? = null,
@Json(name = "drive") val drive: DriveDetail? = null
)
@JsonClass(generateAdapter = true)
data class DriveDetailCar(
@Json(name = "car_id") val carId: Int? = null,
@Json(name = "car_name") val carName: String? = null
)
@JsonClass(generateAdapter = true)
@@ -99,7 +111,7 @@ data class DriveDetail(
@Json(name = "inside_temp_avg") val insideTempAvg: Double? = null,
@Json(name = "energy_consumed_net") val energyConsumedNet: Double? = null,
@Json(name = "consumption_net") val consumptionNet: Double? = null,
@Json(name = "positions") val positions: List<DrivePosition>? = null
@Json(name = "drive_details") val positions: List<DrivePosition>? = null
) {
val id: Int get() = driveId
val distance: Double? get() = odometerDetails?.distance

View File

@@ -151,7 +151,7 @@ class TeslamateRepository @Inject constructor(
val api = getApi() ?: return ApiResult.Error("Server not configured")
val response = api.getDriveDetail(carId, driveId)
if (response.isSuccessful) {
val detail = response.body()?.data
val detail = response.body()?.data?.drive
if (detail != null) {
ApiResult.Success(detail)
} else {

View File

@@ -12,6 +12,7 @@ import androidx.navigation.navArgument
import com.matedroid.ui.screens.battery.BatteryScreen
import com.matedroid.ui.screens.charges.ChargesScreen
import com.matedroid.ui.screens.dashboard.DashboardScreen
import com.matedroid.ui.screens.drives.DriveDetailScreen
import com.matedroid.ui.screens.drives.DrivesScreen
import com.matedroid.ui.screens.mileage.MileageScreen
import com.matedroid.ui.screens.settings.SettingsScreen
@@ -115,6 +116,25 @@ fun NavGraph(
val carId = backStackEntry.arguments?.getInt("carId") ?: return@composable
DrivesScreen(
carId = carId,
onNavigateBack = { navController.popBackStack() },
onNavigateToDriveDetail = { driveId ->
navController.navigate(Screen.DriveDetail.createRoute(carId, driveId))
}
)
}
composable(
route = Screen.DriveDetail.route,
arguments = listOf(
navArgument("carId") { type = NavType.IntType },
navArgument("driveId") { type = NavType.IntType }
)
) { backStackEntry ->
val carId = backStackEntry.arguments?.getInt("carId") ?: return@composable
val driveId = backStackEntry.arguments?.getInt("driveId") ?: return@composable
DriveDetailScreen(
carId = carId,
driveId = driveId,
onNavigateBack = { navController.popBackStack() }
)
}

View File

@@ -1,9 +1,11 @@
package com.matedroid.ui.screens.dashboard
import android.graphics.Paint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Battery5Bar
@@ -43,17 +46,27 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import com.matedroid.data.api.models.BatteryDetails
import com.matedroid.data.api.models.CarGeodata
import com.matedroid.data.api.models.CarStatus
@@ -512,6 +525,9 @@ private fun ChargingCard(status: CarStatus) {
@Composable
private fun LocationCard(geofence: String, status: CarStatus) {
val latitude = status.latitude
val longitude = status.longitude
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
@@ -525,7 +541,7 @@ private fun LocationCard(geofence: String, status: CarStatus) {
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Location",
style = MaterialTheme.typography.labelSmall,
@@ -536,10 +552,64 @@ private fun LocationCard(geofence: String, status: CarStatus) {
style = MaterialTheme.typography.titleMedium
)
}
// Small map showing car location
if (latitude != null && longitude != null) {
Spacer(modifier = Modifier.width(12.dp))
SmallLocationMap(
latitude = latitude,
longitude = longitude,
modifier = Modifier
.width(140.dp)
.height(70.dp)
.clip(RoundedCornerShape(8.dp))
)
}
}
}
}
@Composable
private fun SmallLocationMap(
latitude: Double,
longitude: Double,
modifier: Modifier = Modifier
) {
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
DisposableEffect(Unit) {
Configuration.getInstance().userAgentValue = "MateDroid/1.0"
onDispose { }
}
AndroidView(
factory = { ctx ->
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(false)
// Disable all interactions for this small preview map
setBuiltInZoomControls(false)
isClickable = false
isFocusable = false
val carLocation = GeoPoint(latitude, longitude)
controller.setZoom(15.0)
controller.setCenter(carLocation)
// Add a marker for the car
val marker = Marker(this).apply {
position = carLocation
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ctx.getDrawable(android.R.drawable.ic_menu_mylocation)
}
overlays.add(marker)
}
},
modifier = modifier
)
}
@Composable
private fun VehicleInfoCard(status: CarStatus, units: Units?) {
val distanceUnit = UnitFormatter.getDistanceUnit(units)
@@ -657,19 +727,22 @@ private fun QuickLinksRow(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun QuickLinkItem(
private fun RowScope.QuickLinkItem(
title: String,
icon: ImageVector,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.weight(1f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(

View File

@@ -0,0 +1,725 @@
package com.matedroid.ui.screens.drives
import android.graphics.Color as AndroidColor
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.DeviceThermostat
import androidx.compose.material.icons.filled.Landscape
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.TrendingDown
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import com.matedroid.data.api.models.DriveDetail
import com.matedroid.data.api.models.DrivePosition
import com.matedroid.data.api.models.Units
import com.matedroid.domain.model.UnitFormatter
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Polyline
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DriveDetailScreen(
carId: Int,
driveId: Int,
onNavigateBack: () -> Unit,
viewModel: DriveDetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(carId, driveId) {
viewModel.loadDriveDetail(carId, driveId)
}
LaunchedEffect(uiState.error) {
uiState.error?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Drive Details") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
uiState.driveDetail?.let { detail ->
DriveDetailContent(
detail = detail,
stats = uiState.stats,
units = uiState.units,
modifier = Modifier.padding(padding)
)
}
}
}
}
@Composable
private fun DriveDetailContent(
detail: DriveDetail,
stats: DriveDetailStats?,
units: Units?,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Route header card
RouteHeaderCard(detail = detail)
// Map showing the route
if (!detail.positions.isNullOrEmpty()) {
DriveMapCard(positions = detail.positions)
}
// Stats grid
stats?.let { s ->
// Speed section
StatsSectionCard(
title = "Speed",
icon = Icons.Default.Speed,
stats = listOf(
StatItem("Maximum", UnitFormatter.formatSpeed(s.speedMax.toDouble(), units)),
StatItem("Average", UnitFormatter.formatSpeed(s.speedAvg, units)),
StatItem("Avg (distance)", UnitFormatter.formatSpeed(s.avgSpeedFromDistance, units))
)
)
// Distance & Duration section
StatsSectionCard(
title = "Trip",
icon = Icons.Default.Route,
stats = listOf(
StatItem("Distance", UnitFormatter.formatDistance(s.distance, units)),
StatItem("Duration", formatDuration(s.durationMin)),
StatItem("Efficiency", UnitFormatter.formatEfficiency(s.efficiency, units))
)
)
// Battery section
StatsSectionCard(
title = "Battery",
icon = Icons.Default.BatteryChargingFull,
stats = listOf(
StatItem("Start", "${s.batteryStart}%"),
StatItem("End", "${s.batteryEnd}%"),
StatItem("Used", "${s.batteryUsed}%"),
StatItem("Energy", "%.2f kWh".format(s.energyUsed))
)
)
// Power section
StatsSectionCard(
title = "Power",
icon = Icons.Default.Bolt,
stats = listOf(
StatItem("Max (accel)", "${s.powerMax} kW"),
StatItem("Min (regen)", "${s.powerMin} kW"),
StatItem("Average", "%.1f kW".format(s.powerAvg))
)
)
// Elevation section
if (s.elevationMax > 0 || s.elevationMin > 0) {
StatsSectionCard(
title = "Elevation",
icon = Icons.Default.Landscape,
stats = listOf(
StatItem("Maximum", "${s.elevationMax} m"),
StatItem("Minimum", "${s.elevationMin} m"),
StatItem("Gain", "+${s.elevationGain} m"),
StatItem("Loss", "-${s.elevationLoss} m")
)
)
}
// Temperature section
if (s.outsideTempAvg != null || s.insideTempAvg != null) {
StatsSectionCard(
title = "Temperature",
icon = Icons.Default.DeviceThermostat,
stats = listOfNotNull(
s.outsideTempAvg?.let { StatItem("Outside", UnitFormatter.formatTemperature(it, units)) },
s.insideTempAvg?.let { StatItem("Inside", UnitFormatter.formatTemperature(it, units)) }
)
)
}
// Charts
if (!detail.positions.isNullOrEmpty() && detail.positions.size > 2) {
SpeedChartCard(positions = detail.positions, units = units)
PowerChartCard(positions = detail.positions)
BatteryChartCard(positions = detail.positions)
if (detail.positions.any { it.elevation != null && it.elevation != 0 }) {
ElevationChartCard(positions = detail.positions)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun RouteHeaderCard(detail: DriveDetail) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Start location
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "From",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
Text(
text = detail.startAddress ?: "Unknown location",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
HorizontalDivider(
modifier = Modifier.padding(start = 36.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
// End location
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "To",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
Text(
text = detail.endAddress ?: "Unknown location",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
HorizontalDivider(
modifier = Modifier.padding(start = 36.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
// Date and time
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = formatDateTime(detail.startDate),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
detail.durationStr?.let { duration ->
Text(
text = "Duration: $duration",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
}
}
}
}
@Composable
private fun DriveMapCard(positions: List<DrivePosition>) {
val context = LocalContext.current
val validPositions = positions.filter { it.latitude != null && it.longitude != null }
if (validPositions.isEmpty()) return
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Route Map",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.clip(RoundedCornerShape(8.dp))
) {
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
DisposableEffect(Unit) {
Configuration.getInstance().userAgentValue = "MateDroid/1.0"
onDispose { }
}
AndroidView(
factory = { ctx ->
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
// Create polyline for the route
val geoPoints = validPositions.map { pos ->
GeoPoint(pos.latitude!!, pos.longitude!!)
}
val polyline = Polyline().apply {
setPoints(geoPoints)
outlinePaint.color = primaryColor
outlinePaint.strokeWidth = 8f
outlinePaint.strokeCap = Paint.Cap.ROUND
outlinePaint.strokeJoin = Paint.Join.ROUND
}
overlays.add(polyline)
// Calculate bounding box with padding
if (geoPoints.isNotEmpty()) {
val north = geoPoints.maxOf { it.latitude }
val south = geoPoints.minOf { it.latitude }
val east = geoPoints.maxOf { it.longitude }
val west = geoPoints.minOf { it.longitude }
// Add some padding
val latPadding = (north - south) * 0.15
val lonPadding = (east - west) * 0.15
val boundingBox = BoundingBox(
north + latPadding,
east + lonPadding,
south - latPadding,
west - lonPadding
)
post {
zoomToBoundingBox(boundingBox, false)
invalidate()
}
}
}
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
data class StatItem(val label: String, val value: String)
@Composable
private fun StatsSectionCard(
title: String,
icon: ImageVector,
stats: List<StatItem>
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// Stats grid - 2 or more columns
val chunked = stats.chunked(2)
chunked.forEachIndexed { index, row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
row.forEach { stat ->
StatItemView(
label = stat.label,
value = stat.value,
modifier = Modifier.weight(1f)
)
}
// Fill empty space if odd number
if (row.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
if (index < chunked.size - 1) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
@Composable
private fun StatItemView(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun SpeedChartCard(positions: List<DrivePosition>, units: Units?) {
val speeds = positions.mapNotNull { it.speed?.toFloat() }
if (speeds.size < 2) return
ChartCard(
title = "Speed Profile",
icon = Icons.Default.Speed,
data = speeds,
color = MaterialTheme.colorScheme.primary,
unit = UnitFormatter.getSpeedUnit(units),
convertValue = { value ->
if (units?.isImperial == true) (value * 0.621371f) else value
}
)
}
@Composable
private fun PowerChartCard(positions: List<DrivePosition>) {
val powers = positions.mapNotNull { it.power?.toFloat() }
if (powers.size < 2) return
ChartCard(
title = "Power Profile",
icon = Icons.Default.Bolt,
data = powers,
color = MaterialTheme.colorScheme.tertiary,
unit = "kW",
showZeroLine = true
)
}
@Composable
private fun BatteryChartCard(positions: List<DrivePosition>) {
val batteryLevels = positions.mapNotNull { it.batteryLevel?.toFloat() }
if (batteryLevels.size < 2) return
ChartCard(
title = "Battery Level",
icon = Icons.Default.BatteryChargingFull,
data = batteryLevels,
color = MaterialTheme.colorScheme.secondary,
unit = "%",
fixedMinMax = Pair(0f, 100f)
)
}
@Composable
private fun ElevationChartCard(positions: List<DrivePosition>) {
val elevations = positions.mapNotNull { it.elevation?.toFloat() }
if (elevations.size < 2) return
ChartCard(
title = "Elevation Profile",
icon = Icons.Default.Landscape,
data = elevations,
color = Color(0xFF8B4513), // Brown color for terrain
unit = "m"
)
}
@Composable
private fun ChartCard(
title: String,
icon: ImageVector,
data: List<Float>,
color: Color,
unit: String,
showZeroLine: Boolean = false,
fixedMinMax: Pair<Float, Float>? = null,
convertValue: (Float) -> Float = { it }
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = color
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
val convertedData = data.map { convertValue(it) }
val minValue = fixedMinMax?.first ?: convertedData.minOrNull() ?: 0f
val maxValue = fixedMinMax?.second ?: convertedData.maxOrNull() ?: 1f
val range = (maxValue - minValue).coerceAtLeast(1f)
val surfaceColor = MaterialTheme.colorScheme.onSurface
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
) {
val width = size.width
val height = size.height
val stepX = width / (convertedData.size - 1).coerceAtLeast(1)
// Draw grid lines
val gridLineCount = 4
for (i in 0..gridLineCount) {
val y = height * i / gridLineCount
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1f
)
}
// Draw zero line if needed (for power chart)
if (showZeroLine && minValue < 0 && maxValue > 0) {
val zeroY = height * (1 - (0f - minValue) / range)
drawLine(
color = surfaceColor.copy(alpha = 0.5f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 2f
)
}
// Draw the line chart
if (convertedData.size >= 2) {
for (i in 0 until convertedData.size - 1) {
val x1 = i * stepX
val x2 = (i + 1) * stepX
val y1 = height * (1 - (convertedData[i] - minValue) / range)
val y2 = height * (1 - (convertedData[i + 1] - minValue) / range)
drawLine(
color = color,
start = Offset(x1, y1),
end = Offset(x2, y2),
strokeWidth = 2.5f
)
}
}
// Draw min/max labels
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
this.color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 28f
isAntiAlias = true
}
drawText(
"%.0f".format(maxValue) + " $unit",
8f,
textPaint.textSize + 4f,
textPaint
)
drawText(
"%.0f".format(minValue) + " $unit",
8f,
height - 8f,
textPaint
)
}
}
}
}
}
private fun formatDateTime(dateStr: String?): String {
if (dateStr == null) return "Unknown"
return try {
val dateTime = try {
OffsetDateTime.parse(dateStr).toLocalDateTime()
} catch (e: DateTimeParseException) {
LocalDateTime.parse(dateStr.replace("Z", ""))
}
val formatter = DateTimeFormatter.ofPattern("EEEE, MMM d, yyyy 'at' HH:mm")
dateTime.format(formatter)
} catch (e: Exception) {
dateStr
}
}
private fun formatDuration(minutes: Int): String {
val hours = minutes / 60
val mins = minutes % 60
return if (hours > 0) "${hours}h ${mins}m" else "${mins}m"
}

View File

@@ -0,0 +1,184 @@
package com.matedroid.ui.screens.drives
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matedroid.data.api.models.DriveDetail
import com.matedroid.data.api.models.DrivePosition
import com.matedroid.data.api.models.Units
import com.matedroid.data.repository.ApiResult
import com.matedroid.data.repository.TeslamateRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class DriveDetailUiState(
val isLoading: Boolean = true,
val error: String? = null,
val driveDetail: DriveDetail? = null,
val units: Units? = null,
val stats: DriveDetailStats? = null
)
data class DriveDetailStats(
val speedMax: Int,
val speedAvg: Double,
val speedMin: Int,
val powerMax: Int,
val powerMin: Int,
val powerAvg: Double,
val elevationMax: Int,
val elevationMin: Int,
val elevationGain: Int,
val elevationLoss: Int,
val batteryStart: Int,
val batteryEnd: Int,
val batteryUsed: Int,
val energyUsed: Double,
val efficiency: Double,
val distance: Double,
val durationMin: Int,
val avgSpeedFromDistance: Double,
val outsideTempAvg: Double?,
val insideTempAvg: Double?
)
@HiltViewModel
class DriveDetailViewModel @Inject constructor(
private val repository: TeslamateRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(DriveDetailUiState())
val uiState: StateFlow<DriveDetailUiState> = _uiState.asStateFlow()
private var carId: Int? = null
private var driveId: Int? = null
fun loadDriveDetail(carId: Int, driveId: Int) {
if (this.carId == carId && this.driveId == driveId && _uiState.value.driveDetail != null) {
return // Already loaded
}
this.carId = carId
this.driveId = driveId
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
// Fetch drive detail and units in parallel
val detailResult = repository.getDriveDetail(carId, driveId)
val statusResult = repository.getCarStatus(carId)
val units = when (statusResult) {
is ApiResult.Success -> statusResult.data.units
is ApiResult.Error -> null
}
when (detailResult) {
is ApiResult.Success -> {
val detail = detailResult.data
val stats = calculateStats(detail)
_uiState.update {
it.copy(
isLoading = false,
driveDetail = detail,
units = units,
stats = stats,
error = null
)
}
}
is ApiResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
error = detailResult.message
)
}
}
}
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
private fun calculateStats(detail: DriveDetail): DriveDetailStats {
val positions = detail.positions ?: emptyList()
// Speed stats from positions
val speeds = positions.mapNotNull { it.speed }
val speedMax = speeds.maxOrNull() ?: detail.speedMax ?: 0
val speedMin = speeds.filter { it > 0 }.minOrNull() ?: 0
val speedAvg = if (speeds.isNotEmpty()) speeds.average() else detail.speedAvg ?: 0.0
// Power stats from positions
val powers = positions.mapNotNull { it.power }
val powerMax = powers.maxOrNull() ?: detail.powerMax ?: 0
val powerMin = powers.minOrNull() ?: detail.powerMin ?: 0
val powerAvg = if (powers.isNotEmpty()) powers.average() else 0.0
// Elevation stats
val elevations = positions.mapNotNull { it.elevation }
val elevationMax = elevations.maxOrNull() ?: 0
val elevationMin = elevations.minOrNull() ?: 0
val (elevationGain, elevationLoss) = calculateElevationChange(elevations)
// Battery stats
val batteryLevels = positions.mapNotNull { it.batteryLevel }
val batteryStart = batteryLevels.firstOrNull() ?: detail.startBatteryLevel ?: 0
val batteryEnd = batteryLevels.lastOrNull() ?: detail.endBatteryLevel ?: 0
val batteryUsed = batteryStart - batteryEnd
// Energy and efficiency
val distance = detail.distance ?: 0.0
val energyUsed = detail.energyConsumedNet ?: 0.0
val efficiency = if (distance > 0) (energyUsed * 1000) / distance else 0.0
// Duration and average speed from distance
val durationMin = detail.durationMin ?: 0
val avgSpeedFromDistance = if (durationMin > 0) (distance / durationMin) * 60 else 0.0
return DriveDetailStats(
speedMax = speedMax,
speedAvg = speedAvg,
speedMin = speedMin,
powerMax = powerMax,
powerMin = powerMin,
powerAvg = powerAvg,
elevationMax = elevationMax,
elevationMin = elevationMin,
elevationGain = elevationGain,
elevationLoss = elevationLoss,
batteryStart = batteryStart,
batteryEnd = batteryEnd,
batteryUsed = batteryUsed,
energyUsed = energyUsed,
efficiency = efficiency,
distance = distance,
durationMin = durationMin,
avgSpeedFromDistance = avgSpeedFromDistance,
outsideTempAvg = detail.outsideTempAvg,
insideTempAvg = detail.insideTempAvg
)
}
private fun calculateElevationChange(elevations: List<Int>): Pair<Int, Int> {
if (elevations.size < 2) return Pair(0, 0)
var gain = 0
var loss = 0
for (i in 1 until elevations.size) {
val diff = elevations[i] - elevations[i - 1]
if (diff > 0) gain += diff
else loss += -diff
}
return Pair(gain, loss)
}
}

View File

@@ -1,5 +1,6 @@
package com.matedroid.ui.screens.drives
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -72,6 +73,7 @@ enum class DriveDateFilter(val label: String, val days: Long?) {
fun DrivesScreen(
carId: Int,
onNavigateBack: () -> Unit,
onNavigateToDriveDetail: (driveId: Int) -> Unit,
viewModel: DrivesViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@@ -138,7 +140,8 @@ fun DrivesScreen(
drives = uiState.drives,
summary = uiState.summary,
selectedFilter = selectedFilter,
onFilterSelected = { applyDateFilter(it) }
onFilterSelected = { applyDateFilter(it) },
onDriveClick = onNavigateToDriveDetail
)
}
}
@@ -151,7 +154,8 @@ private fun DrivesContent(
drives: List<DriveData>,
summary: DrivesSummary,
selectedFilter: DriveDateFilter,
onFilterSelected: (DriveDateFilter) -> Unit
onFilterSelected: (DriveDateFilter) -> Unit,
onDriveClick: (driveId: Int) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -202,7 +206,10 @@ private fun DrivesContent(
}
} else {
items(drives, key = { it.id }) { drive ->
DriveItem(drive = drive)
DriveItem(
drive = drive,
onClick = { onDriveClick(drive.id) }
)
}
}
}
@@ -322,9 +329,14 @@ private fun SummaryItem(
}
@Composable
private fun DriveItem(drive: DriveData) {
private fun DriveItem(
drive: DriveData,
onClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)

View File

@@ -15,6 +15,7 @@ moshi = "1.15.1"
datastore = "1.1.1"
securityCrypto = "1.1.0-alpha06"
vico = "2.0.0-beta.2"
osmdroid = "6.1.18"
junit = "4.13.2"
junitExt = "1.2.1"
espresso = "3.6.1"
@@ -62,6 +63,9 @@ security-crypto = { group = "androidx.security", name = "security-crypto", versi
# Charts
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
# Maps
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }