From e709fda72bec9d69455856b3d4ca7882df6814b9 Mon Sep 17 00:00:00 2001 From: Leonardo Colman Lopes Date: Tue, 11 Aug 2026 20:58:39 -0300 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Draw=20the=20hit=20timer=20char?= =?UTF-8?q?t=20with=20Grafima?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the last chart off GraphView, so com.jjoe64 leaves the project altogether: the dependency, its bundle and its licensee exception are all gone. MPAndroidChart stays for the three Stats charts. Two things the swap needed that were not obvious: Grafima animates, and GraphView did not, because it sat in an AndroidView outside Compose's clock. An animation that never settles keeps Compose busy, which stalled the timer's own recomposition and hung two ComposeHitTimerTest cases on a 20s timeout. There is no flag to turn animation off, so the specs are snapped. That is also the behaviour we want: the marker tracks a running timer, and a morph would always draw where the hold was, not where it is. The x axis labels come from each LineDataPoint's own label rather than from axisConfig's formatter, so they are rounded to whole seconds. Setting xLabelFormatter looks like it should work and does nothing. The hold markers lose their size-15 emphasis: dot radius belongs to the chart, not to a series, so colour is what separates them from the curves now. Same treatment as the withdrawal charts. Vertical gridlines are switched back on to match what GraphView drew, since reading where ten seconds falls is the point. holdPointOn and the two study series drop GraphView's DataPoint for a SubjectiveHighPoint, which has value equality, so WhyTenSecondsTest can stop comparing rendered strings. Release APK 11,076,168 -> 11,049,255 bytes (-26,913, -0.24%). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0167SJeKBmjmzhj9gNWcZYfs --- app/build.gradle.kts | 12 +- .../colman/petals/hittimer/WhyTenSeconds.kt | 169 ++++++++++-------- .../colman/petals/hittimer/HoldMarkerTest.kt | 7 +- .../petals/hittimer/WhyTenSecondsTest.kt | 27 ++- gradle/libs.versions.toml | 5 - 5 files changed, 112 insertions(+), 108 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa9de4ba3..a6939277b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -198,11 +198,10 @@ dependencies { // Datastore implementation(libs.bundles.datastore) - // Graph Views - implementation(libs.bundles.graph.view) + // MPAndroidChart: still draws the three Stats charts. + implementation(libs.mp.android.chart) - // Grafima: Compose-native charts. Spiking it on the withdrawal charts; the Stats charts and the - // hit timer still run on MPAndroidChart and GraphView. + // Grafima: Compose-native charts. Draws the withdrawal charts and the hit timer. implementation(libs.grafima) // Apache commons @@ -346,11 +345,6 @@ licensee { because("Apache-2.0 but self-hosted") } - allowUrl("https://github.com/jjoe64/GraphView/blob/master/license.txtl") { - because("Typo of `license.txt`, which is Apache-2.0") - because("https://github.com/jjoe64/GraphView/blob/master/license.txt") - } - allowUrl("https://github.com/devsrsouza/compose-icons/blob/master/LICENSE") { because("MIT License") } diff --git a/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt b/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt index 96c05fdb6..e12d7fa59 100644 --- a/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt +++ b/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt @@ -1,10 +1,12 @@ package br.com.colman.petals.hittimer +import androidx.compose.animation.core.snap import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -17,14 +19,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat import br.com.colman.petals.R.color.darkGreen import br.com.colman.petals.R.color.lightGreen import br.com.colman.petals.R.string._175thc @@ -35,10 +34,16 @@ import br.com.colman.petals.R.string.subjectve_high import br.com.colman.petals.R.string.ten_seconds_introduction import br.com.colman.petals.R.string.ten_seconds_source import br.com.colman.petals.R.string.why_ten_seconds -import com.jjoe64.graphview.GraphView -import com.jjoe64.graphview.series.DataPoint -import com.jjoe64.graphview.series.LineGraphSeries -import com.jjoe64.graphview.series.PointsGraphSeries +import io.grafima.charts.line.LineAnimationConfig +import io.grafima.charts.line.LineAxisConfig +import io.grafima.charts.line.LineChart +import io.grafima.charts.line.LineChartStyle +import io.grafima.charts.line.LineCrosshairConfig +import io.grafima.charts.line.LineCurveType +import io.grafima.charts.line.LineDataPoint +import io.grafima.charts.line.LineDataSet +import io.grafima.charts.line.LineSeries +import kotlin.math.roundToInt /** [holdSeconds] is how long the current hit has been held, marked on both curves as it runs. */ @Preview @@ -60,51 +65,80 @@ fun WhyTenSeconds(holdSeconds: Double = 0.0) { } private const val ChartMaxX = 25.0 +private const val ChartMaxY = 60.0 @Composable fun SubjectiveHigh(holdSeconds: Double = 0.0) { - val primaryColor = MaterialTheme.colors.primary.toArgb() - - AndroidView({ context -> - GraphView(context).apply { - // The curves are added here, not in update: GridLabelRenderer sizes the axis labels on the first - // layout pass, and a GraphView that starts empty lays the horizontal title over the tick numbers. - addCurve(getSubjectiveHighWeakSeries(), ContextCompat.getColor(context, lightGreen), context.getString(_175thc)) - addCurve(getSubjectiveHighStrongSeries(), ContextCompat.getColor(context, darkGreen), context.getString(_355thc)) - - viewport.apply { - isYAxisBoundsManual = true - setMaxY(60.0) - setMinY(0.0) - isXAxisBoundsManual = true - setMaxX(ChartMaxX) - setMinX(0.0) - } + val colors = MaterialTheme.colors + val weak = getSubjectiveHighWeakSeries() + val strong = getSubjectiveHighStrongSeries() - // Drawn in Compose instead: GraphView's legend paints a colour swatch for every series, - // including the untitled hold markers, and its box sits on top of the curves. - legendRenderer.isVisible = false + val verticalAxisTitle = stringResource(subjectve_high) + val horizontalAxisTitle = stringResource(breathhold_duration_seconds) - gridLabelRenderer.apply { - verticalLabelsColor = primaryColor - horizontalLabelsColor = primaryColor - horizontalAxisTitleColor = primaryColor - verticalAxisTitleColor = primaryColor + val curves = listOf( + curve("weak", stringResource(_175thc), weak, colorResource(lightGreen)), + curve("strong", stringResource(_355thc), strong, colorResource(darkGreen)) + ) - verticalAxisTitle = context.resources.getString(subjectve_high) - horizontalAxisTitle = context.getString(breathhold_duration_seconds) - } - } - }, update = { graph -> - // Only the markers move, so only the markers are replaced. The curves stay as laid out. - graph.series.filterIsInstance>().forEach(graph::removeSeries) - - // Both markers in the accent colour, so "where you are" reads as one thing against the two curves. - graph.addHoldMarker(getSubjectiveHighWeakSeries(), holdSeconds, primaryColor) - graph.addHoldMarker(getSubjectiveHighStrongSeries(), holdSeconds, primaryColor) - }) + // Both markers in the accent colour, so "where you are" reads as one thing against the two curves. + // A one-point series rather than an annotation, matching the withdrawal charts. Dot size is a + // property of the chart rather than of a series here, so colour is what separates them. + val markers = listOf(weak, strong).mapIndexed { index, points -> + val (x, y) = holdPointOn(points, holdSeconds, ChartMaxX) + LineSeries("marker-$index", "", listOf(LineDataPoint(x.toFloat(), y.toFloat(), "")), colors.primary) + } + + LineChart( + dataSet = LineDataSet(series = curves + markers, contentDescription = verticalAxisTitle), + modifier = Modifier.fillMaxSize(), + // Linear, not the default cubic: holdPointOn interpolates linearly between the study's three + // measurements, so a smoothed curve would leave the marker floating beside its own line. + style = LineChartStyle(showDots = true, curveType = LineCurveType.Linear), + axisConfig = LineAxisConfig( + gridColor = colors.primary, + // Off by default here, on in the GraphView this replaces. Worth keeping: the whole point of the + // chart is where ten seconds falls, and that is much easier to read against a vertical rule. + showVerticalGrid = true, + axisColor = colors.primary, + labelColor = colors.primary, + yMin = 0f, + yMax = ChartMaxY.toFloat(), + xMin = 0f, + xMax = ChartMaxX.toFloat(), + xAxisTitle = horizontalAxisTitle, + yAxisTitle = verticalAxisTitle + ), + // The marker already says where you are, and the curve is three measured points; a crosshair + // invites reading a precision the study does not carry. + crosshairConfig = LineCrosshairConfig(enabled = false), + // Snapped, which is this library's way of saying "no animation". Two reasons, and either alone + // would be enough: the marker tracks a running timer, so a morph would always be drawing where + // the hold was rather than where it is; and an animation that never settles keeps Compose busy, + // which stalls the timer's own recomposition under test. + animationConfig = LineAnimationConfig( + entrySpec = snap(), + morphSpec = snap(), + staggerMs = 0, + startDelayMs = 0, + seriesStaggerMs = 0 + ) + ) } +private fun curve(id: String, label: String, points: List, color: Color) = LineSeries( + id = id, + label = label, + // The point's own label is what ends up under the x axis, not the axis formatter, so these are + // whole seconds. Left as `toString()` they read "10.0", and the study measured at 0, 10 and 20. + points = points.map { LineDataPoint(it.seconds.toFloat(), it.high.toFloat(), "${it.seconds.roundToInt()}") }, + color = color +) + +/** + * Drawn in Compose rather than by the chart, because the two hold markers are untitled series and a + * generated legend would either name them or leave them out, and "current hold" is worth naming. + */ @Composable private fun SubjectiveHighLegend() { Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(16.dp)) { @@ -122,42 +156,23 @@ private fun LegendEntry(swatch: Color, label: String) { } } -private fun GraphView.addCurve(points: List, seriesColor: Int, seriesTitle: String) { - addSeries( - LineGraphSeries(points.toTypedArray()).apply { - color = seriesColor - isDrawDataPoints = true - title = seriesTitle - } - ) -} - -/** Deliberately left untitled so it does not add a third entry to the legend. */ -private fun GraphView.addHoldMarker(points: List, holdSeconds: Double, markerColor: Int) { - val (x, y) = holdPointOn(points, holdSeconds, ChartMaxX) - addSeries( - PointsGraphSeries(arrayOf(DataPoint(x, y))).apply { - color = markerColor - size = 15f - shape = PointsGraphSeries.Shape.POINT - } - ) -} +/** One measurement from the study: a breathhold of [seconds] against the subjective high reported. */ +data class SubjectiveHighPoint(val seconds: Double, val high: Double) /** * Where a hold of [holdSeconds] sits on [points]: x clamped into the chart, y linearly interpolated * between the study's measurements. Pure - no Android, no Compose - so it is unit-testable. */ -fun holdPointOn(points: List, holdSeconds: Double, maxX: Double): Pair { - val sorted = points.sortedBy { it.x } - val x = holdSeconds.coerceIn(sorted.first().x, minOf(maxX, sorted.last().x)) +fun holdPointOn(points: List, holdSeconds: Double, maxX: Double): Pair { + val sorted = points.sortedBy { it.seconds } + val x = holdSeconds.coerceIn(sorted.first().seconds, minOf(maxX, sorted.last().seconds)) - val upperIndex = sorted.indexOfFirst { it.x >= x }.coerceAtLeast(1) + val upperIndex = sorted.indexOfFirst { it.seconds >= x }.coerceAtLeast(1) val lower = sorted[upperIndex - 1] val upper = sorted[upperIndex] - val span = upper.x - lower.x - val y = if (span == 0.0) upper.y else lower.y + (upper.y - lower.y) * (x - lower.x) / span + val span = upper.seconds - lower.seconds + val y = if (span == 0.0) upper.high else lower.high + (upper.high - lower.high) * (x - lower.seconds) / span return x to y } @@ -166,9 +181,9 @@ fun holdPointOn(points: List, holdSeconds: Double, maxX: Double): Pai * duration. J Pharmacol Exp Ther. 1995. Feb;272(2):560–9. PMID: 7853169. */ fun getSubjectiveHighWeakSeries() = listOf( - DataPoint(0.0, 30.0), - DataPoint(10.0, 40.0), - DataPoint(20.0, 35.0) + SubjectiveHighPoint(0.0, 30.0), + SubjectiveHighPoint(10.0, 40.0), + SubjectiveHighPoint(20.0, 35.0) ) /** @@ -176,7 +191,7 @@ fun getSubjectiveHighWeakSeries() = listOf( * duration. J Pharmacol Exp Ther. 1995. Feb;272(2):560–9. PMID: 7853169. */ fun getSubjectiveHighStrongSeries() = listOf( - DataPoint(0.0, 37.0), - DataPoint(10.0, 47.0), - DataPoint(20.0, 43.0) + SubjectiveHighPoint(0.0, 37.0), + SubjectiveHighPoint(10.0, 47.0), + SubjectiveHighPoint(20.0, 43.0) ) diff --git a/app/src/test/kotlin/br/com/colman/petals/hittimer/HoldMarkerTest.kt b/app/src/test/kotlin/br/com/colman/petals/hittimer/HoldMarkerTest.kt index 69110f715..f5ef76b7c 100644 --- a/app/src/test/kotlin/br/com/colman/petals/hittimer/HoldMarkerTest.kt +++ b/app/src/test/kotlin/br/com/colman/petals/hittimer/HoldMarkerTest.kt @@ -1,6 +1,5 @@ package br.com.colman.petals.hittimer -import com.jjoe64.graphview.series.DataPoint import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.doubles.plusOrMinus import io.kotest.matchers.shouldBe @@ -48,7 +47,11 @@ class HoldMarkerTest : FunSpec({ } test("Unordered points are handled") { - val shuffled = listOf(DataPoint(20.0, 35.0), DataPoint(0.0, 30.0), DataPoint(10.0, 40.0)) + val shuffled = listOf( + SubjectiveHighPoint(20.0, 35.0), + SubjectiveHighPoint(0.0, 30.0), + SubjectiveHighPoint(10.0, 40.0) + ) holdPointOn(shuffled, 5.0, 25.0) shouldBe holdPointOn(weak, 5.0, 25.0) } diff --git a/app/src/test/kotlin/br/com/colman/petals/hittimer/WhyTenSecondsTest.kt b/app/src/test/kotlin/br/com/colman/petals/hittimer/WhyTenSecondsTest.kt index 83c78abd1..9b04840cd 100644 --- a/app/src/test/kotlin/br/com/colman/petals/hittimer/WhyTenSecondsTest.kt +++ b/app/src/test/kotlin/br/com/colman/petals/hittimer/WhyTenSecondsTest.kt @@ -1,28 +1,25 @@ package br.com.colman.petals.hittimer -import com.jjoe64.graphview.series.DataPoint import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly class WhyTenSecondsTest : FunSpec({ + // Compared by value rather than by toString: GraphView's DataPoint had no equals, which is why + // this used to render both sides to strings first. test("getSubjectiveHighWeakSeries should return the correct data points") { - val expected = listOf( - DataPoint(0.0, 30.0), - DataPoint(10.0, 40.0), - DataPoint(20.0, 35.0) - ).map { it.toString() } - val actual = getSubjectiveHighWeakSeries().map { it.toString() } - actual shouldContainExactly expected + getSubjectiveHighWeakSeries() shouldContainExactly listOf( + SubjectiveHighPoint(0.0, 30.0), + SubjectiveHighPoint(10.0, 40.0), + SubjectiveHighPoint(20.0, 35.0) + ) } test("getSubjectiveHighStrongSeries should return the correct data points") { - val expected = listOf( - DataPoint(0.0, 37.0), - DataPoint(10.0, 47.0), - DataPoint(20.0, 43.0) - ).map { it.toString() } - val actual = getSubjectiveHighStrongSeries().map { it.toString() } - actual shouldContainExactly expected + getSubjectiveHighStrongSeries() shouldContainExactly listOf( + SubjectiveHighPoint(0.0, 37.0), + SubjectiveHighPoint(10.0, 47.0), + SubjectiveHighPoint(20.0, 43.0) + ) } }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3aa33b750..242000756 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,6 @@ datastore-android = { module = "androidx.datastore:datastore-preferences", versi datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "datastore" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } grafima = "io.grafima:grafima:1.1.1" -graph-view = "com.jjoe64:graphview:4.2.2" koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } @@ -101,10 +100,6 @@ date-time = [ "kotlin-date-range", ] glance = ["glance", "glance-widgets", "glance-widget-preview", "glance-preview","glance-material"] -graph-view = [ - "graph-view", - "mp-android-chart", -] koin = [ "koin-android", "koin-compose", From 0f9603181d10bda3537b2379fedb1d9cfa657230 Mon Sep 17 00:00:00 2001 From: Leonardo Colman Lopes Date: Wed, 19 Aug 2026 10:20:06 -0300 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=92=84=20Give=20the=20hold=20marker?= =?UTF-8?q?=20a=20dot=20of=20its=20own=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grafima 1.2.0 puts the dot radius on the series rather than only on the chart, so the marker can outweigh the curve it marks again. It was down to colour alone since the migration, which left the 1.75% marker nearly invisible where the two curves run close together. 6.dp against the chart's 3.dp, which lands about where the GraphView marker sat against its curves. --- .../com/colman/petals/hittimer/WhyTenSeconds.kt | 15 ++++++++++++--- gradle/libs.versions.toml | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt b/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt index e12d7fa59..85e51b9bc 100644 --- a/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt +++ b/app/src/main/kotlin/br/com/colman/petals/hittimer/WhyTenSeconds.kt @@ -67,6 +67,9 @@ fun WhyTenSeconds(holdSeconds: Double = 0.0) { private const val ChartMaxX = 25.0 private const val ChartMaxY = 60.0 +/** Twice the chart's own dots, which is roughly where the GraphView marker sat against its curves. */ +private val MarkerDotRadius = 6.dp + @Composable fun SubjectiveHigh(holdSeconds: Double = 0.0) { val colors = MaterialTheme.colors @@ -82,11 +85,17 @@ fun SubjectiveHigh(holdSeconds: Double = 0.0) { ) // Both markers in the accent colour, so "where you are" reads as one thing against the two curves. - // A one-point series rather than an annotation, matching the withdrawal charts. Dot size is a - // property of the chart rather than of a series here, so colour is what separates them. + // A one-point series rather than an annotation, matching the withdrawal charts. Sized past the + // curve dots as well as coloured apart: the marker is the thing you look for while holding. val markers = listOf(weak, strong).mapIndexed { index, points -> val (x, y) = holdPointOn(points, holdSeconds, ChartMaxX) - LineSeries("marker-$index", "", listOf(LineDataPoint(x.toFloat(), y.toFloat(), "")), colors.primary) + LineSeries( + id = "marker-$index", + label = "", + points = listOf(LineDataPoint(x.toFloat(), y.toFloat(), "")), + color = colors.primary, + dotRadius = MarkerDotRadius + ) } LineChart( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 242000756..9cddac99f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "compo datastore-android = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "datastore" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } -grafima = "io.grafima:grafima:1.1.1" +grafima = "io.grafima:grafima:1.2.0" koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }