mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-21 16:02:04 +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:
@@ -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(
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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(),
|
||||||
|
Reference in New Issue
Block a user