1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-20 23:41:45 +02:00

Introduces a feature where selecting a point on the line chart in the OverviewScreen will scroll the measurement list to the corresponding item and highlight it temporarily.

This commit is contained in:
oliexdev
2025-08-18 07:52:46 +02:00
parent 8f7c15e0c5
commit 3855d93046
3 changed files with 95 additions and 12 deletions

View File

@@ -94,6 +94,7 @@ import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.data.lineSeries import com.patrykandpatrick.vico.core.cartesian.data.lineSeries
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener
import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.core.common.Fill import com.patrykandpatrick.vico.core.common.Fill
import com.patrykandpatrick.vico.core.common.LayeredComponent import com.patrykandpatrick.vico.core.common.LayeredComponent
@@ -140,7 +141,8 @@ fun LineChart(
showFilterControls: Boolean, showFilterControls: Boolean,
showFilterTitle: Boolean = false, showFilterTitle: Boolean = false,
showYAxis: Boolean = true, showYAxis: Boolean = true,
targetMeasurementTypeId: Int? = null targetMeasurementTypeId: Int? = null,
onPointSelected: (timestamp: Long) -> Unit = {}
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val userSettingsRepository = sharedViewModel.userSettingRepository val userSettingsRepository = sharedViewModel.userSettingRepository
@@ -568,12 +570,32 @@ fun LineChart(
listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis) listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis)
} }
val lastX = remember { mutableStateOf<Float?>(null) }
val markerVisibilityListener = remember(xToDatesMapForStore) {
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
lastX.value = targets.lastOrNull()?.x?.toFloat()
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
lastX.value = targets.lastOrNull()?.x?.toFloat()
}
override fun onHidden(marker: CartesianMarker) {
val x = lastX.value ?: return
val date = xToDatesMapForStore[x] ?: LocalDate.ofEpochDay(x.toLong())
val timestamp = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
onPointSelected(timestamp)
}
}
}
val chart = rememberCartesianChart( val chart = rememberCartesianChart(
layers = layers.toTypedArray(), layers = layers.toTypedArray(),
startAxis = startYAxis, // left Y-axis startAxis = startYAxis, // left Y-axis
bottomAxis = xAxis, // X-axis bottomAxis = xAxis, // X-axis
endAxis = endYAxis, // right Y-axis endAxis = endYAxis, // right Y-axis
marker = rememberMarker() // Interactive marker for data points marker = rememberMarker(), // Interactive marker for data points
markerVisibilityListener = markerVisibilityListener
) )
CartesianChartHost( CartesianChartHost(

View File

@@ -76,6 +76,7 @@ import com.health.openscale.ui.screen.dialog.TextInputDialog
import com.health.openscale.ui.screen.dialog.TimeInputDialog import com.health.openscale.ui.screen.dialog.TimeInputDialog
import com.health.openscale.ui.screen.dialog.decrementValue import com.health.openscale.ui.screen.dialog.decrementValue
import com.health.openscale.ui.screen.dialog.incrementValue import com.health.openscale.ui.screen.dialog.incrementValue
import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@@ -117,8 +118,8 @@ fun MeasurementDetailScreen(
val lastMeasurementToPreloadFrom by sharedViewModel.lastMeasurementOfSelectedUser.collectAsState() val lastMeasurementToPreloadFrom by sharedViewModel.lastMeasurementOfSelectedUser.collectAsState()
val loadedData by sharedViewModel.currentMeasurementWithValues.collectAsState() val loadedData by sharedViewModel.currentMeasurementWithValues.collectAsState()
val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) } val dateFormat = remember { DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.getDefault()) }
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()) }
// Show a loading indicator if navigation is pending (e.g., after saving). // Show a loading indicator if navigation is pending (e.g., after saving).
if (isPendingNavigation) { if (isPendingNavigation) {

View File

@@ -17,9 +17,13 @@
*/ */
package com.health.openscale.ui.screen.overview package com.health.openscale.ui.screen.overview
import android.R.attr.targetId
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -34,6 +38,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.automirrored.filled.BluetoothSearching
@@ -72,6 +77,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -97,7 +103,11 @@ import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel
import com.health.openscale.ui.screen.bluetooth.ConnectionStatus import com.health.openscale.ui.screen.bluetooth.ConnectionStatus
import com.health.openscale.ui.screen.components.LineChart import com.health.openscale.ui.screen.components.LineChart
import com.health.openscale.ui.screen.components.provideFilterTopBarAction import com.health.openscale.ui.screen.components.provideFilterTopBarAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -281,6 +291,10 @@ fun OverviewScreen(
val selectedUserId by sharedViewModel.selectedUserId.collectAsState() val selectedUserId by sharedViewModel.selectedUserId.collectAsState()
val context = LocalContext.current // Used for Toasts and string resources val context = LocalContext.current // Used for Toasts and string resources
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var highlightedMeasurementId by rememberSaveable { mutableStateOf<Int?>(null) }
// Time filter action for the top bar, specific to this screen's context // Time filter action for the top bar, specific to this screen's context
val timeFilterAction = provideFilterTopBarAction( val timeFilterAction = provideFilterTopBarAction(
sharedViewModel = sharedViewModel, sharedViewModel = sharedViewModel,
@@ -400,7 +414,20 @@ fun OverviewScreen(
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
.padding(bottom = 8.dp), .padding(bottom = 8.dp),
showYAxis = false showYAxis = false,
onPointSelected = { selectedTs ->
val items = enrichedMeasurements.map { it.measurementWithValues }
val targetIndex = findIndexForTimestamp(selectedTs, items)
if (targetIndex >= 0) {
val targetId = items[targetIndex].measurement.id
scope.launch {
listState.animateScrollToItem(index = targetIndex, scrollOffset = 0)
highlightedMeasurementId = targetId
delay(600)
if (highlightedMeasurementId == targetId) highlightedMeasurementId = null
}
}
}
) )
} }
} }
@@ -422,6 +449,7 @@ fun OverviewScreen(
} else if (enrichedMeasurements.isNotEmpty()) { } else if (enrichedMeasurements.isNotEmpty()) {
// Display the list of measurements // Display the list of measurements
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
@@ -444,7 +472,8 @@ fun OverviewScreen(
}, },
onDelete = { onDelete = {
sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement) sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement)
} },
isHighlighted = (highlightedMeasurementId == enrichedItem.measurementWithValues.measurement.id)
) )
} }
} }
@@ -454,6 +483,32 @@ fun OverviewScreen(
} }
} }
private fun findIndexForTimestamp(
selectedTimestamp: Long,
items: List<MeasurementWithValues>
): Int {
if (items.isEmpty()) return -1
val zone = ZoneId.systemDefault()
val selectedDate = Instant.ofEpochMilli(selectedTimestamp).atZone(zone).toLocalDate()
val sameDay = items.withIndex()
.filter { (_, mwv) ->
Instant.ofEpochMilli(mwv.measurement.timestamp).atZone(zone).toLocalDate() == selectedDate
}
if (sameDay.isNotEmpty()) {
return sameDay.minBy { (_, mwv) ->
kotlin.math.abs(mwv.measurement.timestamp - selectedTimestamp)
}.index
}
return items.withIndex().minByOrNull { (_, mwv) ->
kotlin.math.abs(mwv.measurement.timestamp - selectedTimestamp)
}?.index ?: -1
}
/** /**
* A Composable card displayed when no user is currently selected/active. * A Composable card displayed when no user is currently selected/active.
* It prompts the user to add or select a user. * It prompts the user to add or select a user.
@@ -599,8 +654,12 @@ fun MeasurementCard(
measurementWithValues: MeasurementWithValues, measurementWithValues: MeasurementWithValues,
processedValuesForDisplay: List<ValueWithDifference>, processedValuesForDisplay: List<ValueWithDifference>,
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit,
isHighlighted: Boolean = false
) { ) {
val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
val highlightBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
val dateFormatted = remember(measurementWithValues.measurement.timestamp) { val dateFormatted = remember(measurementWithValues.measurement.timestamp) {
SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault()) SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault())
.format(Date(measurementWithValues.measurement.timestamp)) .format(Date(measurementWithValues.measurement.timestamp))
@@ -623,6 +682,12 @@ fun MeasurementCard(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
border = if (isHighlighted) highlightBorder else null,
colors = if (isHighlighted) {
CardDefaults.cardColors(containerColor = highlightColor)
} else {
CardDefaults.cardColors()
},
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column { Column {
@@ -777,11 +842,6 @@ fun MeasurementValueRow(valueWithTrend: ValueWithDifference) {
val context = LocalContext.current val context = LocalContext.current
val iconMeasurementType = remember(type.icon) {type.icon } val iconMeasurementType = remember(type.icon) {type.icon }
// Dynamic content description for the icon based on type name
val iconContentDescription = stringResource(R.string.measurement_type_icon_desc, type.getDisplayName(context))
// Fallback content description if the icon is not found (e.g. shows question mark)
val unknownTypeContentDescription = stringResource(R.string.measurement_type_icon_unknown_desc, type.getDisplayName(context))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),