1
0
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:
oliexdev
2025-08-18 09:40:39 +02:00
parent 3855d93046
commit 220afffb33
4 changed files with 185 additions and 56 deletions

View File

@@ -583,7 +583,10 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
)
}
composable(Routes.GRAPH) {
GraphScreen(sharedViewModel)
GraphScreen(
navController = navController,
sharedViewModel = sharedViewModel
)
}
composable(Routes.TABLE) {
TableScreen(

View File

@@ -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>,

View File

@@ -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
)
)
}
}
}
}
}

View File

@@ -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.