From 47228bc6c4625437d279dd152feb0510d37bbf4a Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 3 Jul 2026 17:27:41 +0200 Subject: [PATCH 1/3] fix: comparison-chart tooltip follows the crosshair and matches other tooltips The power-vs-SoC overlay tooltip (charge & drive comparison) was pinned to the top-center and used a lighter surfaceVariant style, unlike the line/bar chart tooltips which follow the selected point on an inverse-surface chip. This was pre-existing (not from the perf pass) but inconsistent: the tooltip now tracks the crosshair X (clamped on screen) and uses the shared inverseSurface/inverseOnSurface + 8dp style. It keeps its multi-session rows since a given SoC has one power value per compared curve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/components/PowerSocOverlayChart.kt | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt b/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt index 5ba09a49..e1e351a0 100644 --- a/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt @@ -25,6 +25,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -95,6 +98,9 @@ fun PowerSocOverlayChart( val topPad = with(density) { 6.dp.toPx() } var selectedSoc by remember { mutableStateOf(null) } + // Track the container + tooltip widths so the tooltip can follow the crosshair X (clamped on screen). + var containerWidthPx by remember { mutableStateOf(0) } + var tooltipWidthPx by remember { mutableStateOf(0) } // Parent bumps dismissKey (on an outside tap or a scroll) to clear the tooltip. LaunchedEffect(dismissKey) { selectedSoc = null } @@ -107,7 +113,10 @@ fun PowerSocOverlayChart( } } - Box(modifier = modifier.fillMaxWidth()) { + Box(modifier = modifier + .fillMaxWidth() + .onSizeChanged { containerWidthPx = it.width } + ) { Canvas( modifier = Modifier .fillMaxWidth() @@ -206,16 +215,27 @@ fun PowerSocOverlayChart( } } - // Tooltip — SoC and each session's power at the crosshair + // Tooltip — SoC and each session's power at the crosshair. Tracks the crosshair X + // (clamped on screen) and uses the same inverse-surface style as the other chart tooltips. selectedSoc?.let { soc -> val rows = renderCurves.mapNotNull { c -> interpolatePower(c.points, soc)?.let { c to it } } if (rows.isNotEmpty()) { + val tooltipBg = MaterialTheme.colorScheme.inverseSurface + val tooltipFg = MaterialTheme.colorScheme.inverseOnSurface Column( modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 4.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) + .align(Alignment.TopStart) + .offset { + val crosshairX = if (containerWidthPx > 0) { + ((soc - socMin) / socRange) * containerWidthPx + } else 0f + val maxX = (containerWidthPx - tooltipWidthPx).coerceAtLeast(0) + val x = (crosshairX - tooltipWidthPx / 2f).coerceIn(0f, maxX.toFloat()) + IntOffset(x.roundToInt(), 4.dp.roundToPx()) + } + .onSizeChanged { tooltipWidthPx = it.width } + .clip(RoundedCornerShape(8.dp)) + .background(tooltipBg) .padding(horizontal = 10.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { @@ -223,7 +243,7 @@ fun PowerSocOverlayChart( text = "${soc.roundToInt()}$xUnit$xCaption", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = tooltipFg ) rows.forEach { (curve, power) -> Row(verticalAlignment = Alignment.CenterVertically) { @@ -237,7 +257,7 @@ fun PowerSocOverlayChart( Text( text = "${power.roundToInt()} $valueUnit", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = tooltipFg ) } } From 6e197d20adc18a1ee91be865553e964ea8f54909 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Sat, 4 Jul 2026 11:58:25 +0200 Subject: [PATCH 2/3] fix: let vertical swipes scroll the page past line/overlay charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line, dual-axis and power-vs-SoC charts consumed the pointer on touch-down (awaitFirstDown().consume() + drag), so a vertical swipe that began on a chart never reached the enclosing scroll container. Split each into a tap detector (select/toggle the tooltip point) and a detectHorizontalDragGestures scrubber — horizontal drags still scrub the crosshair, but vertical swipes are no longer claimed and fall through to the page scroll. Same tap/scrub behaviour, including the dual-axis time-label-strip guard. The tap-only charts (InteractiveBarChart, TripCostDonut) already let scrolls through; MonthScrollIndicator's drag is an intentional scrollbar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/components/DualAxisLineChart.kt | 74 ++++++++++--------- .../ui/components/OptimizedLineChart.kt | 59 +++++++-------- .../ui/components/PowerSocOverlayChart.kt | 29 ++++---- 3 files changed, 83 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt b/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt index cf158206..33807a44 100644 --- a/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt @@ -5,9 +5,8 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -176,6 +175,32 @@ fun DualAxisLineChart( .fillMaxWidth() .height(totalHeightDp) .onSizeChanged { canvasWidthPx = it.width.toFloat() } + // Tap toggles the tooltip at the nearest point (ignoring the time-label strip below the chart). + .pointerInput(chartDataLeft, chartDataRight) { + if (dataSize < 2) return@pointerInput + detectTapGestures { offset -> + if (offset.y > chartHeightPx) return@detectTapGestures + val cWidth = size.width.toFloat() - rightLabelWidth + val stepX = cWidth / (dataSize - 1).coerceAtLeast(1) + val index = (offset.x / stepX).roundToInt().coerceIn(0, dataSize - 1) + if (selectedPoint?.index == index) { + selectedPoint = null + onXSelected?.invoke(null) + } else { + val fraction = if (dataSize > 1) index.toFloat() / (dataSize - 1) else 0f + val pointX = index * stepX + val leftVal = valueAtFraction(chartDataLeft.displayPoints, fraction) + val rightVal = valueAtFraction(chartDataRight.displayPoints, fraction) + val leftY = if (leftVal != null) { + chartHeightPx * (1 - (leftVal - chartDataLeft.minValue) / chartDataLeft.range) + } else offset.y + selectedPoint = DualSelectedPoint(index, leftVal, rightVal, Offset(pointX, leftY)) + onXSelected?.invoke(fraction) + } + } + } + // Only claim HORIZONTAL drags (scrubbing the crosshair); vertical swipes fall + // through to the enclosing scroll container so the page still scrolls. .pointerInput(chartDataLeft, chartDataRight) { if (dataSize < 2) return@pointerInput @@ -200,38 +225,17 @@ fun DualAxisLineChart( onXSelected?.invoke(fraction) } - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - down.consume() - isUserInteracting = true - - if (down.position.y > chartHeightPx) { - isUserInteracting = false - return@awaitEachGesture - } - - val width = size.width.toFloat() - val cWidth = width - rightLabelWidth - val stepX = cWidth / (dataSize - 1).coerceAtLeast(1) - val initialIndex = ((down.position.x / stepX).roundToInt()).coerceIn(0, dataSize - 1) - val wasSelectedAtSameIndex = selectedPoint?.index == initialIndex - - updateSelection(down.position.x, down.position.y) - - var hasDragged = false - drag(down.id) { change -> - change.consume() - hasDragged = true - updateSelection(change.position.x, change.position.y) - } - - if (!hasDragged && wasSelectedAtSameIndex) { - selectedPoint = null - onXSelected?.invoke(null) - } - - isUserInteracting = false - } + detectHorizontalDragGestures( + onDragStart = { offset -> + if (offset.y <= chartHeightPx) { + isUserInteracting = true + updateSelection(offset.x, offset.y) + } + }, + onDragEnd = { isUserInteracting = false }, + onDragCancel = { isUserInteracting = false }, + onHorizontalDrag = { change, _ -> updateSelection(change.position.x, change.position.y) } + ) } ) { val width = size.width 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 71b3e767..a0cdcb31 100644 --- a/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt @@ -5,9 +5,8 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -142,6 +141,28 @@ fun OptimizedLineChart( .fillMaxWidth() .height(totalHeightDp) .onSizeChanged { canvasWidthPx = it.width.toFloat() } + // Tap toggles the tooltip at the nearest point. + .pointerInput(chartData) { + val points = chartData.displayPoints + if (points.isEmpty()) return@pointerInput + detectTapGestures { offset -> + val width = size.width.toFloat() + val stepX = width / (points.size - 1).coerceAtLeast(1) + val index = (offset.x / stepX).roundToInt().coerceIn(0, points.lastIndex) + if (selectedPoint?.index == index) { + selectedPoint = null + onXSelected?.invoke(null) + } else { + val fraction = if (points.size > 1) index.toFloat() / (points.size - 1) else 0f + val pointX = index * stepX + val pointY = chartHeightPx * (1 - (points[index] - chartData.minValue) / chartData.range) + selectedPoint = SelectedPoint(index, points[index], Offset(pointX, pointY)) + onXSelected?.invoke(fraction) + } + } + } + // Only claim HORIZONTAL drags (scrubbing the crosshair); vertical swipes fall + // through to the enclosing scroll container so the page still scrolls. .pointerInput(chartData) { val points = chartData.displayPoints if (points.isEmpty()) return@pointerInput @@ -157,32 +178,12 @@ fun OptimizedLineChart( onXSelected?.invoke(fraction) } - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - down.consume() - isUserInteracting = true - - val width = size.width.toFloat() - val stepX = width / (points.size - 1).coerceAtLeast(1) - val initialIndex = ((down.position.x / stepX).roundToInt()).coerceIn(0, points.lastIndex) - val wasSelectedAtSameIndex = selectedPoint?.index == initialIndex - - updateSelection(down.position.x) - - var hasDragged = false - drag(down.id) { change -> - change.consume() - hasDragged = true - updateSelection(change.position.x) - } - - if (!hasDragged && wasSelectedAtSameIndex) { - selectedPoint = null - onXSelected?.invoke(null) - } - - isUserInteracting = false - } + detectHorizontalDragGestures( + onDragStart = { offset -> isUserInteracting = true; updateSelection(offset.x) }, + onDragEnd = { isUserInteracting = false }, + onDragCancel = { isUserInteracting = false }, + onHorizontalDrag = { change, _ -> updateSelection(change.position.x) } + ) } ) { val width = size.width diff --git a/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt b/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt index e1e351a0..434cba35 100644 --- a/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt @@ -2,9 +2,8 @@ package com.matedroid.ui.components import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -121,21 +120,21 @@ fun PowerSocOverlayChart( modifier = Modifier .fillMaxWidth() .height(chartHeight + 18.dp) + // Tap sets the crosshair at that SoC. .pointerInput(valid, socMin, socMax) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - down.consume() - fun update(x: Float) { - val frac = (x / size.width).coerceIn(0f, 1f) - selectedSoc = socMin + frac * socRange - } - update(down.position.x) - drag(down.id) { change -> - change.consume() - update(change.position.x) - } + detectTapGestures { offset -> + selectedSoc = socMin + (offset.x / size.width).coerceIn(0f, 1f) * socRange } } + // Only claim HORIZONTAL drags (scrubbing); vertical swipes fall through to the + // enclosing scroll container so the page still scrolls. + .pointerInput(valid, socMin, socMax) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, _ -> + selectedSoc = socMin + (change.position.x / size.width).coerceIn(0f, 1f) * socRange + } + ) + } ) { val w = size.width val plotH = chartHeightPx - topPad From e1db0ce01177a1169420545afddf179a027cc69e Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Sat, 4 Jul 2026 12:23:48 +0200 Subject: [PATCH 3/3] fix: two-finger pan on all embedded maps (match trip detail) The charge-detail, drive-detail, where-was-i and regions-visited maps either panned on a single finger (blocking the page scroll) or didn't release the parent at all. They now use the same gesture as the trip detail map: one finger scrolls the surrounding page, two fingers pan/zoom the map (requestDisallowInterceptTouchEvent only with a second finger). Removed the now-unused MotionEvent imports. The dashboard location card is left as-is: it's an inert map thumbnail that opens the system Maps app on tap, not a movable map. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/screens/charges/ChargeDetailScreen.kt | 15 ++------------- .../ui/screens/drives/DriveDetailScreen.kt | 12 ++---------- .../ui/screens/stats/RegionsVisitedScreen.kt | 5 +++++ .../ui/screens/wherewasi/WhereWasIScreen.kt | 15 ++------------- 4 files changed, 11 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt index bad79276..fcb9c913 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt @@ -2,7 +2,6 @@ package com.matedroid.ui.screens.charges import android.content.Intent import android.net.Uri -import android.view.MotionEvent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -819,19 +818,9 @@ private fun ChargeMapCard(latitude: Double, longitude: Double, accent: Color) { MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) - // Tell the parent vertical scroll to stop intercepting - // touches so single-finger drag pans the map instead - // of scrolling the page. + // One finger scrolls the surrounding page; two fingers pan/zoom the map. setOnTouchListener { v, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_MOVE, - MotionEvent.ACTION_POINTER_DOWN -> - v.parent?.requestDisallowInterceptTouchEvent(true) - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> - v.parent?.requestDisallowInterceptTouchEvent(false) - } + v.parent?.requestDisallowInterceptTouchEvent(event.pointerCount >= 2) false } 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 9e235379..1d5faefe 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 @@ -3,7 +3,6 @@ package com.matedroid.ui.screens.drives import android.content.Intent import android.graphics.Paint import android.net.Uri -import android.view.MotionEvent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -738,16 +737,9 @@ private fun DriveMapCard(positions: List, routeColor: Color) { MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) + // One finger scrolls the surrounding page; two fingers pan/zoom the map. setOnTouchListener { v, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_MOVE, - MotionEvent.ACTION_POINTER_DOWN -> - v.parent?.requestDisallowInterceptTouchEvent(true) - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> - v.parent?.requestDisallowInterceptTouchEvent(false) - } + v.parent?.requestDisallowInterceptTouchEvent(event.pointerCount >= 2) false } diff --git a/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt b/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt index 79ae3dad..ff5a0fd4 100644 --- a/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt @@ -512,6 +512,11 @@ private fun CountryMapCard( MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) + // One finger scrolls the surrounding page; two fingers pan/zoom the map. + setOnTouchListener { v, event -> + v.parent?.requestDisallowInterceptTouchEvent(event.pointerCount >= 2) + false + } } }, update = { mapView -> diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt index 78655b7f..ff54ba28 100644 --- a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt @@ -2,7 +2,6 @@ package com.matedroid.ui.screens.wherewasi import android.content.Intent import android.net.Uri -import android.view.MotionEvent import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -218,19 +217,9 @@ fun WhereWasIScreen( MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) - // Tell the parent vertical scroll to stop intercepting - // touches so single-finger drag pans the map instead - // of scrolling the page. + // One finger scrolls the surrounding page; two fingers pan/zoom the map. setOnTouchListener { v, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_MOVE, - MotionEvent.ACTION_POINTER_DOWN -> - v.parent?.requestDisallowInterceptTouchEvent(true) - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> - v.parent?.requestDisallowInterceptTouchEvent(false) - } + v.parent?.requestDisallowInterceptTouchEvent(event.pointerCount >= 2) false } controller.setZoom(15.0)