diff --git a/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt b/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt index 23a0324e..c67bda56 100644 --- a/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt +++ b/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt @@ -128,14 +128,23 @@ data class DrivePosition( @Json(name = "power") val power: Int? = null, @Json(name = "battery_level") val batteryLevel: Int? = null, @Json(name = "elevation") val elevation: Int? = null, - @Json(name = "climate_info") val climateInfo: DriveClimateInfo? = null + @Json(name = "climate_info") val climateInfo: DriveClimateInfo? = null, + @Json(name = "battery_info") val batteryInfo: DriveBatteryInfo? = null ) { // Convenience accessors val insideTemp: Double? get() = climateInfo?.insideTemp val outsideTemp: Double? get() = climateInfo?.outsideTemp val isClimateOn: Boolean get() = climateInfo?.isClimateOn == true + val isBatteryHeaterOn: Boolean get() = batteryInfo?.batteryHeater == true } +@JsonClass(generateAdapter = true) +data class DriveBatteryInfo( + @Json(name = "battery_heater") val batteryHeater: Boolean? = null, + @Json(name = "battery_heater_on") val batteryHeaterOn: Boolean? = null, + @Json(name = "battery_heater_no_power") val batteryHeaterNoPower: Boolean? = null +) + @JsonClass(generateAdapter = true) data class DriveClimateInfo( @Json(name = "inside_temp") val insideTemp: Double? = null, diff --git a/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt b/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt index 2fc01291..e855ab19 100644 --- a/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt +++ b/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.dp import kotlin.math.abs import kotlin.math.sqrt @@ -51,6 +52,17 @@ data class DualSelectedPoint( val position: Offset ) +/** + * Represents an annotation range to highlight on a chart. + * Fractions are normalized 0.0–1.0 across the X axis. + */ +data class AnnotationRange( + val startFraction: Float, + val endFraction: Float, + val color: Color, + val label: String? = null +) + // ── Data Preparation ──────────────────────────────────────────────────────── fun prepareChartData( @@ -244,6 +256,45 @@ private fun computeMonotoneTangents(xs: FloatArray, ys: FloatArray): FloatArray private val dashEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)) private val crosshairDashEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f)) +/** + * Draws Grafana-style annotation bands on the chart. + * Each range is rendered as a semi-transparent vertical band spanning the full chart height, + * with thin border lines at the start and end edges. + */ +fun DrawScope.drawAnnotationRanges( + ranges: List, + width: Float, + chartHeight: Float +) { + for (range in ranges) { + val startX = range.startFraction * width + val endX = range.endFraction * width + val bandWidth = (endX - startX).coerceAtLeast(1f) + + // Semi-transparent fill band + drawRect( + color = range.color.copy(alpha = 0.12f), + topLeft = Offset(startX, 0f), + size = Size(bandWidth, chartHeight) + ) + + // Thin border lines at edges + val edgeColor = range.color.copy(alpha = 0.4f) + drawLine( + color = edgeColor, + start = Offset(startX, 0f), + end = Offset(startX, chartHeight), + strokeWidth = 1f + ) + drawLine( + color = edgeColor, + start = Offset(endX, 0f), + end = Offset(endX, chartHeight), + strokeWidth = 1f + ) + } +} + /** * Draws dashed grid lines at 25%, 50%, 75% positions (3 interior lines). */ diff --git a/app/src/main/java/com/matedroid/ui/components/FullscreenLineChart.kt b/app/src/main/java/com/matedroid/ui/components/FullscreenLineChart.kt index a95223ea..8bcdef1f 100644 --- a/app/src/main/java/com/matedroid/ui/components/FullscreenLineChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/FullscreenLineChart.kt @@ -60,7 +60,8 @@ fun FullscreenLineChart( convertValue: (Float) -> Float = { it }, externalSelectedFraction: Float? = null, onXSelected: ((Float?) -> Unit)? = null, - fractionToTimeLabel: ((Float) -> String)? = null + fractionToTimeLabel: ((Float) -> String)? = null, + annotationRanges: List = emptyList() ) { if (data.size < 2) return @@ -81,6 +82,7 @@ fun FullscreenLineChart( externalSelectedFraction = externalSelectedFraction, onXSelected = onXSelected, fractionToTimeLabel = fractionToTimeLabel, + annotationRanges = annotationRanges, modifier = Modifier.fillMaxWidth() ) @@ -119,6 +121,7 @@ fun FullscreenLineChart( fixedMinMax = fixedMinMax, timeLabels = timeLabels, convertValue = convertValue, + annotationRanges = annotationRanges, activity = activity, onDismiss = { isFullscreen = false } ) @@ -134,6 +137,7 @@ private fun FullscreenChartOverlay( fixedMinMax: Pair?, timeLabels: List, convertValue: (Float) -> Float, + annotationRanges: List, activity: Activity?, onDismiss: () -> Unit ) { @@ -219,6 +223,7 @@ private fun FullscreenChartOverlay( fixedMinMax = fixedMinMax, timeLabels = timeLabels, convertValue = convertValue, + annotationRanges = annotationRanges, chartHeight = availableChartHeight, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt index d99194dd..614a7199 100644 --- a/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt @@ -63,7 +63,8 @@ fun OptimizedLineChart( chartHeight: Dp = 120.dp, externalSelectedFraction: Float? = null, onXSelected: ((Float?) -> Unit)? = null, - fractionToTimeLabel: ((Float) -> String)? = null + fractionToTimeLabel: ((Float) -> String)? = null, + annotationRanges: List = emptyList() ) { if (data.size < 2) return @@ -181,6 +182,11 @@ fun OptimizedLineChart( // Dashed grid lines (3 interior lines) drawGridLines(gridColor, width, chartHeightPx) + // Annotation ranges (Grafana-style bands behind the data) + if (annotationRanges.isNotEmpty()) { + drawAnnotationRanges(annotationRanges, width, chartHeightPx) + } + // Zero line if needed (for power chart with negative values) if (showZeroLine && chartData.minValue < 0 && chartData.maxValue > 0) { drawZeroLine(surfaceColor, chartData.minValue, chartData.range, width, chartHeightPx) 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 7b8f0e6c..c60dfbd0 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 @@ -77,6 +77,7 @@ import com.matedroid.data.api.models.DrivePosition 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.FullscreenLineChart import com.matedroid.ui.theme.CarColorPalettes import org.osmdroid.config.Configuration @@ -287,6 +288,11 @@ private fun DriveDetailContent( } ?: "" } + // Compute battery heater annotation ranges + val heaterAnnotations = remember(positions) { + computeBatteryHeaterRanges(positions) + } + SpeedChartCard( positions = detail.positions, units = units, @@ -300,14 +306,16 @@ private fun DriveDetailContent( timeLabels = timeLabels, externalSelectedFraction = sharedXFraction, onXSelected = { sharedXFraction = it }, - fractionToTimeLabel = fractionToTimeLabel + fractionToTimeLabel = fractionToTimeLabel, + annotationRanges = heaterAnnotations ) BatteryChartCard( positions = detail.positions, timeLabels = timeLabels, externalSelectedFraction = sharedXFraction, onXSelected = { sharedXFraction = it }, - fractionToTimeLabel = fractionToTimeLabel + fractionToTimeLabel = fractionToTimeLabel, + annotationRanges = heaterAnnotations ) if (detail.positions.any { it.elevation != null && it.elevation != 0 }) { ElevationChartCard( @@ -699,7 +707,8 @@ private fun PowerChartCard( timeLabels: List, externalSelectedFraction: Float? = null, onXSelected: ((Float?) -> Unit)? = null, - fractionToTimeLabel: ((Float) -> String)? = null + fractionToTimeLabel: ((Float) -> String)? = null, + annotationRanges: List = emptyList() ) { val powers = positions.mapNotNull { it.power?.toFloat() } if (powers.size < 2) return @@ -714,7 +723,8 @@ private fun PowerChartCard( timeLabels = timeLabels, externalSelectedFraction = externalSelectedFraction, onXSelected = onXSelected, - fractionToTimeLabel = fractionToTimeLabel + fractionToTimeLabel = fractionToTimeLabel, + annotationRanges = annotationRanges ) } @@ -724,7 +734,8 @@ private fun BatteryChartCard( timeLabels: List, externalSelectedFraction: Float? = null, onXSelected: ((Float?) -> Unit)? = null, - fractionToTimeLabel: ((Float) -> String)? = null + fractionToTimeLabel: ((Float) -> String)? = null, + annotationRanges: List = emptyList() ) { val batteryLevels = positions.mapNotNull { it.batteryLevel?.toFloat() } if (batteryLevels.size < 2) return @@ -739,7 +750,8 @@ private fun BatteryChartCard( timeLabels = timeLabels, externalSelectedFraction = externalSelectedFraction, onXSelected = onXSelected, - fractionToTimeLabel = fractionToTimeLabel + fractionToTimeLabel = fractionToTimeLabel, + annotationRanges = annotationRanges ) } @@ -782,7 +794,8 @@ private fun ChartCard( convertValue: (Float) -> Float = { it }, externalSelectedFraction: Float? = null, onXSelected: ((Float?) -> Unit)? = null, - fractionToTimeLabel: ((Float) -> String)? = null + fractionToTimeLabel: ((Float) -> String)? = null, + annotationRanges: List = emptyList() ) { Card( modifier = Modifier.fillMaxWidth(), @@ -822,6 +835,7 @@ private fun ChartCard( externalSelectedFraction = externalSelectedFraction, onXSelected = onXSelected, fractionToTimeLabel = fractionToTimeLabel, + annotationRanges = annotationRanges, modifier = Modifier.fillMaxWidth() ) } @@ -882,3 +896,60 @@ private fun formatDuration(minutes: Int): String { val mins = minutes % 60 return "%d:%02d".format(hours, mins) } + +/** + * Computes annotation ranges for periods where the battery heater was active. + * + * The battery_heater field tends to flap (single-point true surrounded by false/null gaps + * of ~30-60 positions) because the heater cycles on/off rapidly. To produce clean Grafana-style + * bands, nearby true-runs separated by gaps smaller than [mergeGap] positions are merged + * into a single continuous range. + * + * Returns fractional ranges (0.0–1.0) suitable for chart annotation overlays. + */ +private fun computeBatteryHeaterRanges( + positions: List, + mergeGap: Int = 80 +): List { + if (positions.size < 2) return emptyList() + + // Collect indices where heater is on + val heaterIndices = positions.indices.filter { positions[it].isBatteryHeaterOn } + if (heaterIndices.isEmpty()) return emptyList() + + val heaterColor = Color(0xFFFF9800) // Material Orange + val lastIndex = positions.lastIndex.toFloat() + + // Merge nearby indices into contiguous ranges + val ranges = mutableListOf() + var rangeStart = heaterIndices[0] + var rangeEnd = heaterIndices[0] + + for (i in 1 until heaterIndices.size) { + if (heaterIndices[i] - rangeEnd <= mergeGap) { + // Close enough — extend current range + rangeEnd = heaterIndices[i] + } else { + // Gap too large — emit current range and start a new one + ranges.add( + AnnotationRange( + startFraction = rangeStart / lastIndex, + endFraction = rangeEnd / lastIndex, + color = heaterColor + ) + ) + rangeStart = heaterIndices[i] + rangeEnd = heaterIndices[i] + } + } + // Emit last range + ranges.add( + AnnotationRange( + startFraction = rangeStart / lastIndex, + endFraction = rangeEnd / lastIndex, + color = heaterColor + ) + ) + + return ranges +}