1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-19 23:12:12 +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.layer.LineCartesianLayer
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.common.Fill
import com.patrykandpatrick.vico.core.common.LayeredComponent
@@ -140,7 +141,8 @@ fun LineChart(
showFilterControls: Boolean,
showFilterTitle: Boolean = false,
showYAxis: Boolean = true,
targetMeasurementTypeId: Int? = null
targetMeasurementTypeId: Int? = null,
onPointSelected: (timestamp: Long) -> Unit = {}
) {
val scope = rememberCoroutineScope()
val userSettingsRepository = sharedViewModel.userSettingRepository
@@ -568,12 +570,32 @@ fun LineChart(
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(
layers = layers.toTypedArray(),
startAxis = startYAxis, // left Y-axis
bottomAxis = xAxis, // X-axis
endAxis = endYAxis, // right Y-axis
marker = rememberMarker() // Interactive marker for data points
marker = rememberMarker(), // Interactive marker for data points
markerVisibilityListener = markerVisibilityListener
)
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.decrementValue
import com.health.openscale.ui.screen.dialog.incrementValue
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
@@ -117,8 +118,8 @@ fun MeasurementDetailScreen(
val lastMeasurementToPreloadFrom by sharedViewModel.lastMeasurementOfSelectedUser.collectAsState()
val loadedData by sharedViewModel.currentMeasurementWithValues.collectAsState()
val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) }
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val dateFormat = remember { DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.getDefault()) }
val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()) }
// Show a loading indicator if navigation is pending (e.g., after saving).
if (isPendingNavigation) {

View File

@@ -17,9 +17,13 @@
*/
package com.health.openscale.ui.screen.overview
import android.R.attr.targetId
import android.content.Context
import android.widget.Toast
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.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.components.LineChart
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.util.Date
import java.util.Locale
@@ -281,6 +291,10 @@ fun OverviewScreen(
val selectedUserId by sharedViewModel.selectedUserId.collectAsState()
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
val timeFilterAction = provideFilterTopBarAction(
sharedViewModel = sharedViewModel,
@@ -400,7 +414,20 @@ fun OverviewScreen(
.fillMaxWidth()
.height(200.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()) {
// Display the list of measurements
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
@@ -444,7 +472,8 @@ fun OverviewScreen(
},
onDelete = {
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.
* It prompts the user to add or select a user.
@@ -599,8 +654,12 @@ fun MeasurementCard(
measurementWithValues: MeasurementWithValues,
processedValuesForDisplay: List<ValueWithDifference>,
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) {
SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault())
.format(Date(measurementWithValues.measurement.timestamp))
@@ -623,6 +682,12 @@ fun MeasurementCard(
Card(
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)
) {
Column {
@@ -777,11 +842,6 @@ fun MeasurementValueRow(valueWithTrend: ValueWithDifference) {
val context = LocalContext.current
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(
modifier = Modifier.fillMaxWidth(),