From 46eb8192121fc8972880277f0acf120b1188e865 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 20 Mar 2026 15:48:07 +0100 Subject: [PATCH 1/2] feat: add speed distribution histogram to drive details Add a speed histogram chart at the end of the drive detail charts (before weather), matching the Teslamate Grafana drive details panel. - Buckets of 10 km/h (or 5 mph when imperial) - Values shown as percentages: one decimal for <10%, no decimals otherwise - Uses InteractiveBarChart with tappable bars and tooltips - Add speed_distribution string to all 4 locales Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/screens/drives/DriveDetailScreen.kt | 108 ++++++++++++++++++ app/src/main/res/values-ca/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 5 files changed, 116 insertions(+) diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt index c60dfbd..c46cd15 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt @@ -78,7 +78,9 @@ import com.matedroid.data.api.models.Units import com.matedroid.data.repository.WeatherPoint import com.matedroid.domain.model.UnitFormatter import com.matedroid.ui.components.AnnotationRange +import com.matedroid.ui.components.BarChartData import com.matedroid.ui.components.FullscreenLineChart +import com.matedroid.ui.components.InteractiveBarChart import com.matedroid.ui.theme.CarColorPalettes import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory @@ -327,6 +329,11 @@ private fun DriveDetailContent( fractionToTimeLabel = fractionToTimeLabel ) } + + SpeedHistogramCard( + positions = detail.positions, + units = units + ) } } @@ -781,6 +788,107 @@ private fun ElevationChartCard( ) } +@Composable +private fun SpeedHistogramCard( + positions: List, + units: Units? +) { + val speeds = positions.mapNotNull { it.speed } + if (speeds.size < 2) return + + val isImperial = units?.isImperial == true + val bucketSize = if (isImperial) 5 else 10 + val speedUnit = UnitFormatter.getSpeedUnit(units) + + val histogramData = remember(speeds, isImperial) { + buildSpeedHistogram(speeds, bucketSize, isImperial) + } + + if (histogramData.isEmpty()) return + + 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 = Icons.Default.Speed, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.speed_distribution), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + InteractiveBarChart( + data = histogramData, + barColor = MaterialTheme.colorScheme.primary, + showEveryNthLabel = if (histogramData.size > 8) 2 else 1, + valueFormatter = { pct -> + if (pct < 10.0) "%.1f%%".format(pct) else "%.0f%%".format(pct) + }, + yAxisFormatter = { pct -> + if (pct < 10.0) "%.1f%%".format(pct) else "%.0f%%".format(pct) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +/** + * Builds speed histogram data with buckets of [bucketSize] units. + * Returns bar chart data with percentage values. + */ +private fun buildSpeedHistogram( + speeds: List, + bucketSize: Int, + isImperial: Boolean +): List { + if (speeds.isEmpty()) return emptyList() + + val convertedSpeeds = if (isImperial) { + speeds.map { (it * 0.621371).toInt() } + } else { + speeds + } + + val minSpeed = (convertedSpeeds.min() / bucketSize) * bucketSize + val maxSpeed = ((convertedSpeeds.max() / bucketSize) + 1) * bucketSize + val total = convertedSpeeds.size.toDouble() + + val buckets = mutableListOf() + var bucketStart = minSpeed + while (bucketStart < maxSpeed) { + val bucketEnd = bucketStart + bucketSize + val count = convertedSpeeds.count { it >= bucketStart && it < bucketEnd } + val pct = (count / total) * 100.0 + buckets.add( + BarChartData( + label = "$bucketStart", + value = pct, + displayValue = if (pct < 10.0) "%.1f%%".format(pct) else "%.0f%%".format(pct) + ) + ) + bucketStart = bucketEnd + } + + return buckets +} + @Composable private fun ChartCard( title: String, diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 188cea9..a926804 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -346,6 +346,8 @@ Perfil de potència Nivell de bateria Perfil d\'altitud + + Distribució de velocitat Temps al llarg del trajecte diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3ca4158..9f0f637 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -346,6 +346,8 @@ Perfil de potencia Nivel de batería Perfil de altitud + + Distribución de velocidad Clima en el trayecto diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8ce6faa..2f14e9b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -346,6 +346,8 @@ Profilo potenza Livello batteria Profilo altitudine + + Distribuzione velocità Meteo lungo il percorso diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7403a6..58c3615 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,6 +346,8 @@ Power Profile Battery Level Elevation Profile + + Speed Distribution Weather along the way From 94247cd3d9398d39a7d7574d0812e32f3dd5a0f9 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 20 Mar 2026 15:50:02 +0100 Subject: [PATCH 2/2] fix: remove double conversion in speed histogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TeslaMate API pre-converts speed to the user's unit system. Speeds are already in km/h or mph — no conversion needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/screens/drives/DriveDetailScreen.kt | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt index c46cd15..e1cf20c 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt @@ -800,8 +800,8 @@ private fun SpeedHistogramCard( val bucketSize = if (isImperial) 5 else 10 val speedUnit = UnitFormatter.getSpeedUnit(units) - val histogramData = remember(speeds, isImperial) { - buildSpeedHistogram(speeds, bucketSize, isImperial) + val histogramData = remember(speeds, bucketSize) { + buildSpeedHistogram(speeds, bucketSize) } if (histogramData.isEmpty()) return @@ -852,29 +852,25 @@ private fun SpeedHistogramCard( /** * Builds speed histogram data with buckets of [bucketSize] units. * Returns bar chart data with percentage values. + * + * Note: The TeslaMate API pre-converts speed to the user's unit system, + * so speeds are already in km/h or mph — no conversion needed here. */ private fun buildSpeedHistogram( speeds: List, - bucketSize: Int, - isImperial: Boolean + bucketSize: Int ): List { if (speeds.isEmpty()) return emptyList() - val convertedSpeeds = if (isImperial) { - speeds.map { (it * 0.621371).toInt() } - } else { - speeds - } - - val minSpeed = (convertedSpeeds.min() / bucketSize) * bucketSize - val maxSpeed = ((convertedSpeeds.max() / bucketSize) + 1) * bucketSize - val total = convertedSpeeds.size.toDouble() + val minSpeed = (speeds.min() / bucketSize) * bucketSize + val maxSpeed = ((speeds.max() / bucketSize) + 1) * bucketSize + val total = speeds.size.toDouble() val buckets = mutableListOf() var bucketStart = minSpeed while (bucketStart < maxSpeed) { val bucketEnd = bucketStart + bucketSize - val count = convertedSpeeds.count { it >= bucketStart && it < bucketEnd } + val count = speeds.count { it >= bucketStart && it < bucketEnd } val pct = (count / total) * 100.0 buckets.add( BarChartData(