mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-21 16:02:04 +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) {
|
composable(Routes.GRAPH) {
|
||||||
GraphScreen(sharedViewModel)
|
GraphScreen(
|
||||||
|
navController = navController,
|
||||||
|
sharedViewModel = sharedViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.TABLE) {
|
composable(Routes.TABLE) {
|
||||||
TableScreen(
|
TableScreen(
|
||||||
|
@@ -72,6 +72,8 @@ import kotlinx.coroutines.flow.mapLatest
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -811,6 +813,34 @@ class SharedViewModel(
|
|||||||
ContextCompat.startForegroundService(application.applicationContext, intent)
|
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(
|
private fun triggerSyncUpdateMeasurement(
|
||||||
measurementToSave: Measurement,
|
measurementToSave: Measurement,
|
||||||
valuesToSave: List<MeasurementValue>,
|
valuesToSave: List<MeasurementValue>,
|
||||||
|
@@ -17,28 +17,58 @@
|
|||||||
*/
|
*/
|
||||||
package com.health.openscale.ui.screen.graph
|
package com.health.openscale.ui.screen.graph
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import android.content.Context
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.material3.SheetValue
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.SharedViewModel
|
||||||
|
import com.health.openscale.ui.screen.ValueWithDifference
|
||||||
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 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GraphScreen(sharedViewModel: SharedViewModel) {
|
fun GraphScreen(
|
||||||
|
navController: NavController,
|
||||||
|
sharedViewModel: SharedViewModel
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val isLoading by sharedViewModel.isBaseDataLoading.collectAsState()
|
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(
|
val timeFilterAction = provideFilterTopBarAction(
|
||||||
sharedViewModel = sharedViewModel,
|
sharedViewModel = sharedViewModel,
|
||||||
@@ -46,16 +76,12 @@ fun GraphScreen(sharedViewModel: SharedViewModel) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(timeFilterAction) {
|
LaunchedEffect(timeFilterAction) {
|
||||||
sharedViewModel.setTopBarTitle("Graph")
|
sharedViewModel.setTopBarTitle(context.getString(R.string.route_title_graph))
|
||||||
|
sharedViewModel.setTopBarActions(listOfNotNull(timeFilterAction))
|
||||||
val actions = mutableListOf<SharedViewModel.TopBarAction>()
|
|
||||||
timeFilterAction?.let { actions.add(it) }
|
|
||||||
|
|
||||||
sharedViewModel.setTopBarActions(actions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
if (isLoading && allMeasurementsWithValuesRaw.isEmpty()) {
|
if (isLoading && allMeasurementsWithValues.isEmpty()) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
@@ -64,9 +90,104 @@ fun GraphScreen(sharedViewModel: SharedViewModel) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
sharedViewModel = sharedViewModel,
|
sharedViewModel = sharedViewModel,
|
||||||
screenContextName = UserPreferenceKeys.GRAPH_SCREEN_CONTEXT,
|
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,
|
showYAxis = false,
|
||||||
onPointSelected = { selectedTs ->
|
onPointSelected = { selectedTs ->
|
||||||
val items = enrichedMeasurements.map { it.measurementWithValues }
|
val items = enrichedMeasurements.map { it.measurementWithValues }
|
||||||
val targetIndex = findIndexForTimestamp(selectedTs, items)
|
|
||||||
if (targetIndex >= 0) {
|
sharedViewModel.findClosestMeasurement(selectedTs, items)
|
||||||
val targetId = items[targetIndex].measurement.id
|
?.let { (targetIndex, mwv) ->
|
||||||
scope.launch {
|
val targetId = mwv.measurement.id
|
||||||
listState.animateScrollToItem(index = targetIndex, scrollOffset = 0)
|
scope.launch {
|
||||||
highlightedMeasurementId = targetId
|
listState.animateScrollToItem(index = targetIndex, scrollOffset = 0)
|
||||||
delay(600)
|
highlightedMeasurementId = targetId
|
||||||
if (highlightedMeasurementId == targetId) highlightedMeasurementId = null
|
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.
|
* 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.
|
||||||
|
Reference in New Issue
Block a user