mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-19 23:12:12 +02:00
Enable point selection on graph screen to show measurement details
This commit is contained in:
@@ -583,7 +583,10 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
|
||||
)
|
||||
}
|
||||
composable(Routes.GRAPH) {
|
||||
GraphScreen(sharedViewModel)
|
||||
GraphScreen(
|
||||
navController = navController,
|
||||
sharedViewModel = sharedViewModel
|
||||
)
|
||||
}
|
||||
composable(Routes.TABLE) {
|
||||
TableScreen(
|
||||
|
@@ -72,6 +72,8 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
@@ -811,6 +813,34 @@ class SharedViewModel(
|
||||
ContextCompat.startForegroundService(application.applicationContext, intent)
|
||||
}
|
||||
|
||||
fun findClosestMeasurement(
|
||||
selectedTimestamp: Long,
|
||||
items: List<MeasurementWithValues>
|
||||
): Pair<Int, MeasurementWithValues>? {
|
||||
if (items.isEmpty()) return null
|
||||
|
||||
val zone = ZoneId.systemDefault()
|
||||
val selectedDate = Instant.ofEpochMilli(selectedTimestamp).atZone(zone).toLocalDate()
|
||||
|
||||
// Kandidaten am selben Tag
|
||||
val sameDay = items.withIndex().filter { (_, mwv) ->
|
||||
Instant.ofEpochMilli(mwv.measurement.timestamp).atZone(zone).toLocalDate() == selectedDate
|
||||
}
|
||||
|
||||
// Auswahl treffen
|
||||
val best = if (sameDay.isNotEmpty()) {
|
||||
sameDay.minBy { (_, mwv) ->
|
||||
kotlin.math.abs(mwv.measurement.timestamp - selectedTimestamp)
|
||||
}
|
||||
} else {
|
||||
items.withIndex().minByOrNull { (_, mwv) ->
|
||||
kotlin.math.abs(mwv.measurement.timestamp - selectedTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
return best?.let { it.index to it.value }
|
||||
}
|
||||
|
||||
private fun triggerSyncUpdateMeasurement(
|
||||
measurementToSave: Measurement,
|
||||
valuesToSave: List<MeasurementValue>,
|
||||
|
@@ -17,28 +17,58 @@
|
||||
*/
|
||||
package com.health.openscale.ui.screen.graph
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.health.openscale.R
|
||||
import com.health.openscale.core.data.Trend
|
||||
import com.health.openscale.core.database.UserPreferenceKeys
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import com.health.openscale.ui.navigation.Routes
|
||||
import com.health.openscale.ui.screen.SharedViewModel
|
||||
import com.health.openscale.ui.screen.ValueWithDifference
|
||||
import com.health.openscale.ui.screen.components.LineChart
|
||||
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
||||
|
||||
import com.health.openscale.ui.screen.overview.MeasurementValueRow
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GraphScreen(sharedViewModel: SharedViewModel) {
|
||||
fun GraphScreen(
|
||||
navController: NavController,
|
||||
sharedViewModel: SharedViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isLoading by sharedViewModel.isBaseDataLoading.collectAsState()
|
||||
val allMeasurementsWithValuesRaw by sharedViewModel.allMeasurementsForSelectedUser.collectAsState()
|
||||
val allMeasurementsWithValues by sharedViewModel.allMeasurementsForSelectedUser.collectAsState()
|
||||
val selectedUserId by sharedViewModel.selectedUserId.collectAsState()
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
var sheetMeasurementId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
|
||||
var lastTapId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var lastTapAt by rememberSaveable { mutableStateOf(0L) }
|
||||
val doubleTapWindowMs = 600L
|
||||
|
||||
val timeFilterAction = provideFilterTopBarAction(
|
||||
sharedViewModel = sharedViewModel,
|
||||
@@ -46,16 +76,12 @@ fun GraphScreen(sharedViewModel: SharedViewModel) {
|
||||
)
|
||||
|
||||
LaunchedEffect(timeFilterAction) {
|
||||
sharedViewModel.setTopBarTitle("Graph")
|
||||
|
||||
val actions = mutableListOf<SharedViewModel.TopBarAction>()
|
||||
timeFilterAction?.let { actions.add(it) }
|
||||
|
||||
sharedViewModel.setTopBarActions(actions)
|
||||
sharedViewModel.setTopBarTitle(context.getString(R.string.route_title_graph))
|
||||
sharedViewModel.setTopBarActions(listOfNotNull(timeFilterAction))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (isLoading && allMeasurementsWithValuesRaw.isEmpty()) {
|
||||
if (isLoading && allMeasurementsWithValues.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
@@ -64,9 +90,104 @@ fun GraphScreen(sharedViewModel: SharedViewModel) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
sharedViewModel = sharedViewModel,
|
||||
screenContextName = UserPreferenceKeys.GRAPH_SCREEN_CONTEXT,
|
||||
showFilterControls = true
|
||||
showFilterControls = true,
|
||||
onPointSelected = { selectedTs ->
|
||||
val result = sharedViewModel.findClosestMeasurement(selectedTs, allMeasurementsWithValues)
|
||||
?: return@LineChart
|
||||
val (idx, mwv) = result
|
||||
val id = mwv.measurement.id
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (lastTapId == id && (now - lastTapAt) <= doubleTapWindowMs) {
|
||||
sheetMeasurementId = id
|
||||
lastTapId = null
|
||||
lastTapAt = 0L
|
||||
} else {
|
||||
lastTapId = id
|
||||
lastTapAt = now
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sheetMeasurement = remember(sheetMeasurementId, allMeasurementsWithValues) {
|
||||
allMeasurementsWithValues.firstOrNull { it.measurement.id == sheetMeasurementId }
|
||||
}
|
||||
|
||||
if (sheetMeasurementId != null && sheetMeasurement != null) {
|
||||
LaunchedEffect(sheetMeasurementId) {
|
||||
sheetState.expand()
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { sheetMeasurementId = null },
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle() },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
) {
|
||||
val mwv = sheetMeasurement
|
||||
val dateStr = remember(mwv.measurement.timestamp) {
|
||||
val dateTimeFormatter =
|
||||
DateFormat.getDateTimeInstance(
|
||||
DateFormat.MEDIUM,
|
||||
DateFormat.SHORT,
|
||||
Locale.getDefault()
|
||||
)
|
||||
dateTimeFormatter.format(Date(mwv.measurement.timestamp))
|
||||
}
|
||||
|
||||
val visibleValues = mwv.values.filter { it.type.isEnabled }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = dateStr,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val uid = selectedUserId
|
||||
IconButton(
|
||||
enabled = uid != null,
|
||||
onClick = {
|
||||
sheetMeasurementId = null
|
||||
if (uid != null) {
|
||||
navController.navigate(Routes.measurementDetail(mwv.measurement.id, uid))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.action_edit_measurement_desc)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { sheetMeasurementId = null }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.cancel_button)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
visibleValues.forEach { v ->
|
||||
MeasurementValueRow(
|
||||
ValueWithDifference(
|
||||
currentValue = v,
|
||||
difference = null,
|
||||
trend = Trend.NOT_APPLICABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -417,16 +417,17 @@ fun OverviewScreen(
|
||||
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
|
||||
|
||||
sharedViewModel.findClosestMeasurement(selectedTs, items)
|
||||
?.let { (targetIndex, mwv) ->
|
||||
val targetId = mwv.measurement.id
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(index = targetIndex, scrollOffset = 0)
|
||||
highlightedMeasurementId = targetId
|
||||
delay(600)
|
||||
if (highlightedMeasurementId == targetId) highlightedMeasurementId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -483,32 +484,6 @@ 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.
|
||||
|
Reference in New Issue
Block a user