Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/src/main/java/com/matedroid/data/api/models/DriveModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<AnnotationRange>,
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).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnnotationRange> = emptyList()
) {
if (data.size < 2) return

Expand All @@ -81,6 +82,7 @@ fun FullscreenLineChart(
externalSelectedFraction = externalSelectedFraction,
onXSelected = onXSelected,
fractionToTimeLabel = fractionToTimeLabel,
annotationRanges = annotationRanges,
modifier = Modifier.fillMaxWidth()
)

Expand Down Expand Up @@ -119,6 +121,7 @@ fun FullscreenLineChart(
fixedMinMax = fixedMinMax,
timeLabels = timeLabels,
convertValue = convertValue,
annotationRanges = annotationRanges,
activity = activity,
onDismiss = { isFullscreen = false }
)
Expand All @@ -134,6 +137,7 @@ private fun FullscreenChartOverlay(
fixedMinMax: Pair<Float, Float>?,
timeLabels: List<String>,
convertValue: (Float) -> Float,
annotationRanges: List<AnnotationRange>,
activity: Activity?,
onDismiss: () -> Unit
) {
Expand Down Expand Up @@ -219,6 +223,7 @@ private fun FullscreenChartOverlay(
fixedMinMax = fixedMinMax,
timeLabels = timeLabels,
convertValue = convertValue,
annotationRanges = annotationRanges,
chartHeight = availableChartHeight,
modifier = Modifier
.fillMaxWidth()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnnotationRange> = emptyList()
) {
if (data.size < 2) return

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -287,6 +288,11 @@ private fun DriveDetailContent(
} ?: ""
}

// Compute battery heater annotation ranges
val heaterAnnotations = remember(positions) {
computeBatteryHeaterRanges(positions)
}

SpeedChartCard(
positions = detail.positions,
units = units,
Expand All @@ -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(
Expand Down Expand Up @@ -699,7 +707,8 @@ private fun PowerChartCard(
timeLabels: List<String>,
externalSelectedFraction: Float? = null,
onXSelected: ((Float?) -> Unit)? = null,
fractionToTimeLabel: ((Float) -> String)? = null
fractionToTimeLabel: ((Float) -> String)? = null,
annotationRanges: List<AnnotationRange> = emptyList()
) {
val powers = positions.mapNotNull { it.power?.toFloat() }
if (powers.size < 2) return
Expand All @@ -714,7 +723,8 @@ private fun PowerChartCard(
timeLabels = timeLabels,
externalSelectedFraction = externalSelectedFraction,
onXSelected = onXSelected,
fractionToTimeLabel = fractionToTimeLabel
fractionToTimeLabel = fractionToTimeLabel,
annotationRanges = annotationRanges
)
}

Expand All @@ -724,7 +734,8 @@ private fun BatteryChartCard(
timeLabels: List<String>,
externalSelectedFraction: Float? = null,
onXSelected: ((Float?) -> Unit)? = null,
fractionToTimeLabel: ((Float) -> String)? = null
fractionToTimeLabel: ((Float) -> String)? = null,
annotationRanges: List<AnnotationRange> = emptyList()
) {
val batteryLevels = positions.mapNotNull { it.batteryLevel?.toFloat() }
if (batteryLevels.size < 2) return
Expand All @@ -739,7 +750,8 @@ private fun BatteryChartCard(
timeLabels = timeLabels,
externalSelectedFraction = externalSelectedFraction,
onXSelected = onXSelected,
fractionToTimeLabel = fractionToTimeLabel
fractionToTimeLabel = fractionToTimeLabel,
annotationRanges = annotationRanges
)
}

Expand Down Expand Up @@ -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<AnnotationRange> = emptyList()
) {
Card(
modifier = Modifier.fillMaxWidth(),
Expand Down Expand Up @@ -822,6 +835,7 @@ private fun ChartCard(
externalSelectedFraction = externalSelectedFraction,
onXSelected = onXSelected,
fractionToTimeLabel = fractionToTimeLabel,
annotationRanges = annotationRanges,
modifier = Modifier.fillMaxWidth()
)
}
Expand Down Expand Up @@ -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<DrivePosition>,
mergeGap: Int = 80
): List<AnnotationRange> {
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<AnnotationRange>()
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
}
Loading