diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt index c4160d1a..a9738c1c 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -583,7 +583,10 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { ) } composable(Routes.GRAPH) { - GraphScreen(sharedViewModel) + GraphScreen( + navController = navController, + sharedViewModel = sharedViewModel + ) } composable(Routes.TABLE) { TableScreen( diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt index 68fdaa95..583dce4f 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt @@ -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 + ): Pair? { + 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, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt index 04a8beb0..8f75d035 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt @@ -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(null) } + + var lastTapId by rememberSaveable { mutableStateOf(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() - 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 + ) + ) + } + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index a177c72d..089361a5 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -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 -): 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.