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
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ open class BarChart : BarLineChartBase<BarData>, BarDataProvider {

dataRenderer = BarChartRenderer(this, mAnimator, viewPortHandler, mDrawRoundedBars, mRoundedBarRadius)

setHighlighter(BarHighlighter(this))
highlighter = BarHighlighter(this)

xAxis.spaceMin = 0.5f
xAxis.spaceMax = 0.5f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ abstract class BarLineChartBase<T : BarLineScatterCandleBubbleData<IBarLineScatt

xAxisRenderer = XAxisRenderer(viewPortHandler, mXAxis, mLeftAxisTransformer)

setHighlighter(ChartHighlighter(this))
if (highlighter == null) // otherwise it overwrites highlighter from successors
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Paget96 under the hood this was the magic.
I'm not sure what you exactly are using. You have to check, if highlighter is set twice

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image image

here is highlight callback
image

And whenever I click chart it returns onNothingSelected(), issue is somewhere behind setOnChartValueSelectedListener()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please share your code ...
Otherwise you are alone

Copy link
Collaborator

@Paget96 Paget96 Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.paget96.batteryguru.R
import com.paget96.batteryguru.ui.screens.batteryhealth.BatteryHealthTimeRange
import com.paget96.batteryguru.ui.screens.batteryhealth.SessionData
import com.paget96.batteryguru.ui.theme.extended
import com.paget96.batteryguru.utils.DateUtils
import com.paget96.batteryguru.utils.batteryhealth.BatteryHealth.Companion.MINIMUM_PERCENTAGE_FOR_VERIFIED_ESTIMATION
import info.appdev.charting.animation.Easing
import info.appdev.charting.charts.CombinedChart
import info.appdev.charting.charts.ScatterChart
import info.appdev.charting.components.AxisBase
import info.appdev.charting.components.XAxis
import info.appdev.charting.components.YAxis
import info.appdev.charting.data.CombinedData
import info.appdev.charting.data.Entry
import info.appdev.charting.data.LineData
import info.appdev.charting.data.LineDataSet
import info.appdev.charting.data.ScatterData
import info.appdev.charting.data.ScatterDataSet
import info.appdev.charting.formatter.IAxisValueFormatter
import info.appdev.charting.highlight.Highlight
import info.appdev.charting.interfaces.datasets.IScatterDataSet
import info.appdev.charting.listener.OnChartValueSelectedListener

data class BatteryHistoryChartColors(
    val onSurface: Color,
    val primary: Color,
    val secondaryContainer: Color,
    val surfaceContainerHigh: Color,
    val error: Color,
    val success: Color,
    val red: Color,
    val green: Color
)

data class BatteryHealthHistoryChartConfiguration(
    val showHealthCheckSessions: Boolean = true,
    val showChargingSessions: Boolean = false,
    val timeRange: BatteryHealthTimeRange = BatteryHealthTimeRange.LAST_30_DAYS
)

@Composable
fun BatteryHealthHistoryChart(
    modifier: Modifier = Modifier,
    entries: List<SessionData>,
    colors: BatteryHistoryChartColors? = null,
    chartConfiguration: BatteryHealthHistoryChartConfiguration = BatteryHealthHistoryChartConfiguration(),
    onSessionSelected: ((SessionData?) -> Unit)
) {

    val chartColors = colors ?: BatteryHistoryChartColors(
        onSurface = MaterialTheme.colorScheme.onSurface,
        primary = MaterialTheme.colorScheme.primary,
        secondaryContainer = MaterialTheme.colorScheme.secondaryContainer,
        surfaceContainerHigh = MaterialTheme.colorScheme.surfaceContainerHigh,
        error = MaterialTheme.colorScheme.error,
        success = MaterialTheme.colorScheme.secondary,
        red = MaterialTheme.extended.red,
        green = MaterialTheme.extended.green,
    )

    var loadedInitial by remember { mutableStateOf(false) }

    val chartAlpha by animateFloatAsState(
        targetValue = if (!loadedInitial) 0.3f else 1f,
        animationSpec = tween(400)
    )

    // run the initial highlight only once per new "entries" payload
    var didInitialHighlight by remember(entries) { mutableStateOf(false) }

    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(192.dp),
        contentAlignment = Alignment.Center
    ) {
        AndroidView(
            factory = { context ->
                CombinedChart(context).apply {
                    // basic styling
                    setNoDataText("")
                    legend.isEnabled = false
                    description.isEnabled = false

                    animateXY(250, 250, Easing.easeInOutCubic)

                    viewPortHandler.setMaximumScaleX(4f)
                    viewPortHandler.setMaximumScaleY(4f)

                    setTouchEnabled(true)
                    isPinchZoom = true
                    isScaleXEnabled = true
                    isScaleYEnabled = false
                    isDoubleTapToZoomEnabled = false
                    isAutoScaleMinMax = true

                    isHighlightPerDragEnabled = true

                    setExtraOffsets(0f, 16f, 0f, 16f)
                    drawOrder = mutableListOf(
                        CombinedChart.DrawOrder.LINE,
                        CombinedChart.DrawOrder.SCATTER
                    )

                    isDrawMarkersEnabled = false

                    // left axis = percentage
                    axisLeft.apply {
                        isDrawAxisLine = false

                        axisMinimum = 0f
                        axisMaximum = 125f

                        setLabelCount(6, true)

                        //TODO: ENABLE WHEN CHART UPDATES
                        //isDrawGridLinesBehindDataEnabled = true
                        gridColor = chartColors.onSurface.copy(alpha = 0.25f).toArgb()
                        enableGridDashedLine(10f, 5f, 0f)
                        gridLineWidth = 1f

                        textColor = chartColors.primary.toArgb()
                        textSize = 10f

                        valueFormatter = object : IAxisValueFormatter {
                            override fun getFormattedValue(
                                value: Float,
                                axis: AxisBase?
                            ) = "${value.toInt()}%"
                        }
                    }

                    // right axis unused
                    axisRight.isEnabled = false

                    // X axis = date
                    xAxis.apply {
                        isDrawAxisLine = false

                        //TODO: ENABLE WHEN CHART UPDATES
                        //isDrawGridLinesBehindDataEnabled = true
                        gridColor = chartColors.onSurface.copy(alpha = 0.25f).toArgb()
                        enableGridDashedLine(10f, 5f, 0f)
                        gridLineWidth = 0.5f

                        position = XAxis.XAxisPosition.BOTTOM

                        textColor = chartColors.primary.toArgb()
                        textSize = 10f

                        yOffset = 20f
                        setLabelCount(3, false)

                        valueFormatter = object : IAxisValueFormatter {
                            override fun getFormattedValue(
                                value: Float,
                                axis: AxisBase?
                            ) = DateUtils.convertMsToDate(
                                context,
                                value.toLong(),
                                showTime = false,
                                showYear = false,
                                useSeconds = false,
                                useMilliseconds = false,
                                useShortDate = true
                            )
                        }
                    }

                    setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
                        override fun onValueSelected(entry: Entry, highlight: Highlight) {
                            onSessionSelected(entry.data as? SessionData)
                        }

                        override fun onNothingSelected() {
                            onSessionSelected(null)
                        }
                    })
                }
            },
            update = { chart ->
                // 1) sort and split entries
                val sorted = entries.sortedBy { it.timestamp }
                val verifiedPoints = mutableListOf<Entry>()
                val otherPoints = mutableListOf<Entry>()
                val cumulativeLine = mutableListOf<Entry>()
                var cumulativeCapacitySum = 0f
                var cumulativePercentageSum = 0f

                sorted.forEach { e ->
                    val perc = e.percentageScreenOn + e.percentageScreenOff
                    val cap = e.capacityScreenOn + e.capacityScreenOff
                    val design = e.batteryDesignCapacity.toFloat()
                    val estSess = e.estimatedCapacity.toFloat()

                    if (perc >= MINIMUM_PERCENTAGE_FOR_VERIFIED_ESTIMATION) {
                        cumulativeCapacitySum += cap
                        cumulativePercentageSum += perc
                        val estCum = cumulativeCapacitySum / cumulativePercentageSum * 100f
                        val sessPct = (estSess / design) * 100f
                        val cumPct = (estCum / design) * 100f

                        verifiedPoints.add(Entry(e.timestamp.toFloat(), sessPct, e))
                        cumulativeLine.add(Entry(e.timestamp.toFloat(), cumPct, e))
                    } else {
                        val sessPct = (estSess / design) * 100f
                        otherPoints.add(Entry(e.timestamp.toFloat(), sessPct, e))
                    }
                }

                // 2) build scatter data sets
                val scatterDataSets = mutableListOf<IScatterDataSet>()

                if (verifiedPoints.isNotEmpty() && chartConfiguration.showHealthCheckSessions) {
                    scatterDataSets += ScatterDataSet(verifiedPoints, "Verified").apply {
                        setScatterShape(ScatterChart.ScatterShape.X)
                        color = chartColors.primary.toArgb()
                        scatterShapeSize = 8f
                        isDrawValues = false
                    }
                }

                if (otherPoints.isNotEmpty() && chartConfiguration.showChargingSessions) {
                    scatterDataSets += ScatterDataSet(otherPoints, "Other").apply {
                        setScatterShape(ScatterChart.ScatterShape.TRIANGLE)
                        color = chartColors.error.toArgb()
                        scatterShapeSize = 8f
                        isDrawValues = false
                    }
                }

                val scatterData = ScatterData(scatterDataSets)

                // 3) build line data set
                val lineData = if (cumulativeLine.isNotEmpty()) {
                    LineData(LineDataSet(cumulativeLine, "Health (%)").apply {
                        color = chartColors.primary.toArgb()
                        axisDependency = YAxis.AxisDependency.LEFT
                        lineWidth = 2.5f
                        isDrawCircles = false
                        isDrawValues = false
                        lineMode = LineDataSet.Mode.CUBIC_BEZIER
                    })
                } else LineData()

                // 4) combine and set
                CombinedData().also { cd ->
                    cd.setData(scatterData)
                    cd.setData(lineData)
                    chart.data = cd

                    if (!didInitialHighlight) {
                        val combined = chart.data
                        val scatter = combined?.scatterData

                        val target = scatter?.dataSets
                            ?.firstOrNull { it.isVisible && it.entryCount > 0 }

                        if (target != null) {
                            val lastIdx = target.entryCount - 1
                            val last = target.getEntryForIndex(lastIdx)

                            val dsIndexInScatter = scatter.getIndexOfDataSet(target)
                            val dataIndexInCombined = combined.allData.indexOf(scatter)

                            if (last != null) {
                                val hl = Highlight(
                                    last.x,
                                    last.y,
                                    dsIndexInScatter,
                                    dataIndexInCombined
                                )

                                chart.post {
                                    chart.highlightValue(hl, true)
                                    chart.moveViewToX(last.x)
                                }
                            }
                        } else {
                            chart.post {
                                chart.highlightValue(null, true)
                            }
                            onSessionSelected(null)
                        }

                        didInitialHighlight = true
                    }
                }

                // 5) animate + invalidate
                chart.animateXY(250, 250, Easing.easeInOutCubic)
                chart.invalidate()
            },
            modifier = Modifier
                .fillMaxSize()
                .alpha(chartAlpha)
        )

        // show “measuring…” until we have any entries
        LaunchedEffect(entries) {
            if (entries.isNotEmpty())
                loadedInitial = true
        }

        AnimatedVisibility(
            visible = !loadedInitial,
            enter = fadeIn(tween(400)),
            exit = fadeOut(tween(400))
        ) {
            Text(
                text = stringResource(R.string.measuring_or_not_enough_data_to_display),
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurface,
                textAlign = TextAlign.Center,
                maxLines = 2,
                modifier = Modifier
                    .align(Alignment.Center)
                    .padding(dimensionResource(R.dimen.card_inner_padding))
            )
        }
    }
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think issue here is difference in X and Y axis value types. As i understand on some is float on some is long.
Seems like the highlighter cannot found anything on X axis.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I wrote "Please share your code ..." this means I need something to debug.
I don't do bug searching on code snippets. Mostly it's waste of time and relays on too much assumptions

Copy link
Collaborator

@Paget96 Paget96 Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhm, the only difference I see now is that bar chart and line chart configuration uses round numbers as 1.0f, 2.0f for X indexes.
For this combined one, it relies on more precise X axis, which is timestamp.
image
If you made any changes to be mor estrict, it could be due to this, in simple words, X value don't match.

I have tried to many ways to solve it but no highlight. PIE chart works, line as well, barchart works, only issues are on combined. IN my app, only this combined chart uses timestamp, I'll update you later whether X value is issue.

Everything worked well before moving to v4.x.x, I have run through my commits on my project, changes are straightforward, only conversion to renamed functions.

Copy link
Collaborator

@Paget96 Paget96 Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have tried many code changes, including making a simple combined chart with data from the app, click on entries don't work at all, X axis values are not the problem. From what I can see, it seems like it act like we click outside of the entry and triggers onNothingSelected(). Need to mention that this is debug build, no code obfuscations.

highlighter = ChartHighlighter(this)

chartTouchListener = BarLineChartTouchListener(this, viewPortHandler.matrixTouch, 3f)

Expand Down
6 changes: 0 additions & 6 deletions chartLib/src/main/kotlin/info/appdev/charting/charts/Chart.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import info.appdev.charting.data.ChartData
import info.appdev.charting.data.Entry
import info.appdev.charting.formatter.DefaultValueFormatter
import info.appdev.charting.formatter.IValueFormatter
import info.appdev.charting.highlight.ChartHighlighter
import info.appdev.charting.highlight.Highlight
import info.appdev.charting.highlight.IHighlighter
import info.appdev.charting.interfaces.dataprovider.base.IBaseProvider
Expand Down Expand Up @@ -152,7 +151,6 @@ abstract class Chart<T : ChartData<out IDataSet<out Entry>>> : ViewGroup, IBaseP
protected var dataRenderer: DataRenderer? = null

var highlighter: IHighlighter? = null
protected set

/**
* Returns the ViewPortHandler of the chart that is responsible for the
Expand Down Expand Up @@ -1046,10 +1044,6 @@ abstract class Chart<T : ChartData<out IDataSet<out Entry>>> : ViewGroup, IBaseP
/**
* Returns a recyclable PointF instance.
*/
fun setHighlighter(highlighter: ChartHighlighter<*>?) {
this.highlighter = highlighter
}

override val centerOfView: PointF
get() = this.center

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ open class CombinedChart : BarLineChartBase<CombinedData>, CombinedDataProvider
get() = this@CombinedChart.candleData
}

setHighlighter(CombinedHighlighter(this, barDataProvider))
highlighter = CombinedHighlighter(this, barDataProvider)

// Old default behaviour
this@CombinedChart.isHighlightFullBar = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ open class HorizontalBarChart : BarChart {
mRightAxisTransformer = TransformerHorizontalBarChart(viewPortHandler)

dataRenderer = HorizontalBarChartRenderer(this, mAnimator, viewPortHandler)
setHighlighter(HorizontalBarHighlighter(this))
highlighter = HorizontalBarHighlighter(this)

axisRendererLeft = YAxisRendererHorizontalBarChart(viewPortHandler, mAxisLeft, mLeftAxisTransformer)
axisRendererRight = YAxisRendererHorizontalBarChart(viewPortHandler, mAxisRight, mRightAxisTransformer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ open class ChartHighlighter<T : BarLineScatterCandleBubbleDataProvider<*>>(prote
* @param x touch position
* @param y touch position
*/
protected open fun getHighlightsAtXValue(xVal: Float, x: Float, y: Float): MutableList<Highlight>? {
override fun getHighlightsAtXValue(xVal: Float, x: Float, y: Float): MutableList<Highlight>? {
highlightBuffer.clear()

data?.let { myData ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ interface IHighlighter {
* Returns a Highlight object corresponding to the given x- and y- touch positions in pixels.
*/
fun getHighlight(x: Float, y: Float): Highlight?

fun getHighlightsAtXValue(xVal: Float, x: Float, y: Float): MutableList<Highlight>?
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,10 @@ abstract class PieRadarHighlighter<T : PieRadarChartBase<*>>(protected var chart
* Returns the closest Highlight object of the given objects based on the touch position inside the chart.
*/
protected abstract fun getClosestHighlight(index: Int, x: Float, y: Float): Highlight?

override fun getHighlightsAtXValue(
xVal: Float,
x: Float,
y: Float
): MutableList<Highlight>? = null
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading