Skip to content

Conversation

@hannesa2
Copy link
Collaborator

@hannesa2 hannesa2 commented Jan 19, 2026

It needs #665 in advance

@hannesa2 hannesa2 added the bugfix Something isn't working label Jan 19, 2026
@hannesa2 hannesa2 marked this pull request as draft January 19, 2026 06:35
@hannesa2 hannesa2 force-pushed the FixHighlightInCombinedChart branch from 1e0c21f to 67e6f7f Compare January 19, 2026 07:41
@hannesa2
Copy link
Collaborator Author

hannesa2 commented Jan 19, 2026

StartTest_smokeTestStart-25-CombinedChartActivity-CombinedChart-click.pngscreenshot

StartTest_smokeTestStart-25-CombinedChartActivity-CombinedChart-click2020.pngscreenshot

StartTest_smokeTestStart-25-CombinedChartActivity-CombinedChart-click7070.pngscreenshot

@hannesa2
Copy link
Collaborator Author

@Paget96 as #665 is now merged, we see the improvement here.
This was the desired behavior to have an evidence to see that it solves it.

This makes the tests professional and makes sure, that the click event now works for ever (as long as nobody merges stuff too early)

@hannesa2 hannesa2 marked this pull request as ready for review January 19, 2026 08:04
@hannesa2 hannesa2 merged commit ae556c5 into master Jan 19, 2026
3 of 4 checks passed
@hannesa2 hannesa2 deleted the FixHighlightInCombinedChart branch January 19, 2026 08:59
@Paget96
Copy link
Collaborator

Paget96 commented Jan 19, 2026

video_2026-01-20_00-05-48.mp4

Here is video. Still not see the clicks

@hannesa2
Copy link
Collaborator Author

OK, the click in the sample works, the click in your app not. That means a deeper debug investigation is needed.
Can you share your code ?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants