diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt index d5b3d3ff..8658df8a 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -32,6 +33,7 @@ import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -143,6 +145,7 @@ fun LineChart( targetMeasurementTypeId: Int? = null, onPointSelected: (timestamp: Long) -> Unit = {} ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val showTypeFilterRowSetting by rememberContextualBooleanSetting( @@ -190,22 +193,30 @@ fun LineChart( currentSelectedTypeIdsStrings.mapNotNull { stringId: String -> stringId.toIntOrNull() }.toSet() } + // Flows to provide current filter state to the ViewModel's data fetching logic val timeRangeFlow = remember { MutableStateFlow(uiSelectedTimeRange) } - LaunchedEffect(uiSelectedTimeRange) { - timeRangeFlow.value = uiSelectedTimeRange - } + LaunchedEffect(uiSelectedTimeRange) { timeRangeFlow.value = uiSelectedTimeRange } val typesToSmoothFlow = remember { MutableStateFlow(currentSelectedTypeIntIds) } - LaunchedEffect(currentSelectedTypeIntIds) { - typesToSmoothFlow.value = currentSelectedTypeIntIds - } + LaunchedEffect(currentSelectedTypeIntIds) { typesToSmoothFlow.value = currentSelectedTypeIntIds } + + // State for managing chart data loading + var isChartDataLoading by remember { mutableStateOf(true) } + val initialChartDataValue = remember { emptyList() } val smoothedData by sharedViewModel .smoothedEnrichedMeasurements( timeRangeFlow = timeRangeFlow, typesToSmoothAndDisplayFlow = typesToSmoothFlow ) - .collectAsStateWithLifecycle(initialValue = emptyList()) + .collectAsStateWithLifecycle(initialValue = initialChartDataValue) + + // Update loading state once data (or an empty list after loading) is received + LaunchedEffect(smoothedData) { + if (smoothedData !== initialChartDataValue) { + isChartDataLoading = false + } + } val fullyFilteredEnrichedMeasurements = remember(smoothedData, currentSelectedTypeIntIds) { sharedViewModel.filterEnrichedMeasurementsByTypes(smoothedData, currentSelectedTypeIntIds) @@ -278,7 +289,6 @@ fun LineChart( ) } - if (showFilterTitle) { Row( modifier = Modifier @@ -296,7 +306,7 @@ fun LineChart( Text( text = stringResource( R.string.line_chart_filter_title_template, - uiSelectedTimeRange.getDisplayName(LocalContext.current), + uiSelectedTimeRange.getDisplayName(context), measurementsWithValues.size ), style = MaterialTheme.typography.bodyMedium, @@ -305,43 +315,32 @@ fun LineChart( } } - // Early exit if there's absolutely nothing to do (no plotable types AND no data AND filter not visible) - // This is a general "empty state" for the chart area. - if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && !effectiveShowTypeFilterRow && targetMeasurementTypeId == null) { - Box( - modifier = Modifier - .weight(1f) // Takes up available vertical space in the Column - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Text( - // Provide a more specific message if no types are plottable at all. - if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) - stringResource(R.string.line_chart_no_plottable_types) - else stringResource(R.string.line_chart_no_data_to_display) - ) - } - return@Column // Exits the Column Composable early - }else if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && targetMeasurementTypeId != null) { - // Specific empty state when a target type is specified, but no data exists for it. - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Text( - stringResource( + var showNoDataMessage by remember { mutableStateOf(false) } + var noDataMessageText by remember { mutableStateOf("") } + + LaunchedEffect( + isChartDataLoading, lineTypesToActuallyPlot, measurementsWithValues, + effectiveShowTypeFilterRow, targetMeasurementTypeId, allAvailableMeasurementTypes + ) { + if (!isChartDataLoading) { + if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && !effectiveShowTypeFilterRow && targetMeasurementTypeId == null) { + showNoDataMessage = true + noDataMessageText = if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) + context.getString(R.string.line_chart_no_plottable_types) + else context.getString(R.string.line_chart_no_data_to_display) + } else if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && targetMeasurementTypeId != null) { + showNoDataMessage = true + noDataMessageText = context.getString( R.string.line_chart_no_data_for_type_in_range, - allAvailableMeasurementTypes.find { it.id == targetMeasurementTypeId }?.getDisplayName(LocalContext.current) - ?: stringResource(R.string.line_chart_this_type_placeholder) - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) + allAvailableMeasurementTypes.find { it.id == targetMeasurementTypeId }?.getDisplayName(context) + ?: context.getString(R.string.line_chart_this_type_placeholder) + ) + } else { + showNoDataMessage = false + } + } else { + showNoDataMessage = false } - return@Column } // State to hold the processed series data for the chart. @@ -388,232 +387,223 @@ fun LineChart( } } - // Second check: if after processing, no series are available to plot (e.g., data existed but not for selected types). - if (seriesEntries.isEmpty()) { - Box( - modifier = Modifier - .weight(1f) // Takes up available vertical space - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - val message = if (lineTypesToActuallyPlot.isEmpty() && effectiveShowTypeFilterRow) { - // Filter row is visible, but either nothing is selected or no data for selection. - if (measurementsWithValues.isEmpty() && currentSelectedTypeIntIds.isNotEmpty()) stringResource(R.string.line_chart_no_data_for_selected_types) - else if (measurementsWithValues.isEmpty()) stringResource(R.string.line_chart_no_data_to_display) - else stringResource(R.string.line_chart_please_select_types) - } else if (lineTypesToActuallyPlot.isEmpty()) { - // Filter not visible and no types to plot (likely because default is empty or no plottable types overall). - if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) - stringResource(R.string.line_chart_no_plottable_types) - else stringResource(R.string.line_chart_no_data_or_types_to_select) - } else if (measurementsWithValues.isEmpty()){ // Types selected, but no data entries at all. - stringResource(R.string.line_chart_no_data_to_display) + when { + isChartDataLoading -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - else { // Types selected, data exists, but not for these specific types. - stringResource(R.string.line_chart_no_data_for_selected_types) + } + showNoDataMessage -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = noDataMessageText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) } - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) } - return@Column // Exits the Column Composable early - } + seriesEntries.isEmpty() -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val message = if (lineTypesToActuallyPlot.isEmpty() && effectiveShowTypeFilterRow) { + if (currentSelectedTypeIntIds.isNotEmpty() && smoothedData.none { m -> m.measurementWithValues.values.any { v -> v.type.id in currentSelectedTypeIntIds } }) { + stringResource(R.string.line_chart_no_data_for_selected_types) + } else if (currentSelectedTypeIntIds.isEmpty()){ + stringResource(R.string.line_chart_please_select_types) + } else { + stringResource(R.string.line_chart_no_data_to_display) + } + } else if (lineTypesToActuallyPlot.isEmpty()) { + if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) + stringResource(R.string.line_chart_no_plottable_types) + else stringResource(R.string.line_chart_no_data_or_types_to_select) + } else if (smoothedData.isEmpty() && measurementsWithValues.isEmpty() && currentSelectedTypeIntIds.isNotEmpty()){ + stringResource(R.string.line_chart_no_data_to_display) + } + else { + stringResource(R.string.line_chart_no_data_for_selected_types) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + else -> { + val seriesEntriesForStartAxis = remember(seriesEntries) { + seriesEntries.filter { (type, _) -> !type.isOnRightYAxis } + } + val typeColorsForStartAxis = remember(seriesEntriesForStartAxis) { + seriesEntriesForStartAxis.map { (type, _) -> if (type.color != 0) Color(type.color) else Color.Gray } + } - val seriesEntriesForStartAxis = remember(seriesEntries) { - seriesEntries.filter { (type, _) -> - !type.isOnRightYAxis - } - } - val typeColorsForStartAxis = remember(seriesEntriesForStartAxis) { - seriesEntriesForStartAxis.map { (type, _) -> - if (type.color != 0) Color(type.color) else Color.Gray - } - } + val seriesEntriesForEndAxis = remember(seriesEntries) { + seriesEntries.filter { (type, _) -> type.isOnRightYAxis } + } + val typeColorsForEndAxis = remember(seriesEntriesForEndAxis) { + seriesEntriesForEndAxis.map { (type, _) -> if (type.color != 0) Color(type.color) else Color.Gray } + } - val seriesEntriesForEndAxis = remember(seriesEntries) { - seriesEntries.filter { (type, _) -> - type.isOnRightYAxis - } - } - val typeColorsForEndAxis = remember(seriesEntriesForEndAxis) { - seriesEntriesForEndAxis.map { (type, _) -> - if (type.color != 0) Color(type.color) else Color.Gray - } - } + val modelProducer = remember { CartesianChartModelProducer() } - val modelProducer = remember { CartesianChartModelProducer() } - - LaunchedEffect(seriesEntriesForStartAxis, seriesEntriesForEndAxis, xToDatesMapForStore) { - if (seriesEntriesForStartAxis.isNotEmpty() || seriesEntriesForEndAxis.isNotEmpty()) { - modelProducer.runTransaction { - if (seriesEntriesForStartAxis.isNotEmpty()) { - lineSeries { - seriesEntriesForStartAxis.forEach { (_, sortedDateValuePairs) -> - val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } - val yValues = sortedDateValuePairs.map { it.second } - if (xValues.isNotEmpty()) { - series(x = xValues, y = yValues) + LaunchedEffect(seriesEntriesForStartAxis, seriesEntriesForEndAxis, xToDatesMapForStore) { + if (seriesEntriesForStartAxis.isNotEmpty() || seriesEntriesForEndAxis.isNotEmpty()) { + modelProducer.runTransaction { + if (seriesEntriesForStartAxis.isNotEmpty()) { + lineSeries { + seriesEntriesForStartAxis.forEach { (_, sortedDateValuePairs) -> + val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } + val yValues = sortedDateValuePairs.map { it.second } + if (xValues.isNotEmpty()) series(x = xValues, y = yValues) + } } } - } - } - - if (seriesEntriesForEndAxis.isNotEmpty()) { - lineSeries { - seriesEntriesForEndAxis.forEach { (_, sortedDateValuePairs) -> - val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } - val yValues = sortedDateValuePairs.map { it.second } - if (xValues.isNotEmpty()) { - series(x = xValues, y = yValues) + if (seriesEntriesForEndAxis.isNotEmpty()) { + lineSeries { + seriesEntriesForEndAxis.forEach { (_, sortedDateValuePairs) -> + val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } + val yValues = sortedDateValuePairs.map { it.second } + if (xValues.isNotEmpty()) series(x = xValues, y = yValues) + } } } + extras { it[X_TO_DATE_MAP_KEY] = xToDatesMapForStore } + } + } else { + modelProducer.runTransaction { + lineSeries {} // Clear primary series + lineSeries {} // Clear secondary series + extras { it.remove(X_TO_DATE_MAP_KEY) } } } - extras { it[X_TO_DATE_MAP_KEY] = xToDatesMapForStore } } - } else { - modelProducer.runTransaction { - lineSeries {} - lineSeries {} - extras { it.remove(X_TO_DATE_MAP_KEY) } + + val scrollState = rememberVicoScrollState() + val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) + val xAxisValueFormatter = rememberXAxisValueFormatter(X_TO_DATE_MAP_KEY, DATE_FORMATTER) + val yAxisValueFormatter = CartesianValueFormatter.decimal() + + val xAxis = if (targetMeasurementTypeId == null) { + HorizontalAxis.rememberBottom(valueFormatter = xAxisValueFormatter, guideline = null) + } else null + + val rangeProvider = remember { + object : CartesianLayerRangeProvider { + override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { + val r = maxY - minY + return if (r == 0.0) minY - 1.0 else floor(minY - 0.1 * r) + } + override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { + val r = maxY - minY + return if (r == 0.0) maxY + 1.0 else ceil(maxY + 0.1 * r) + } + } } - } - } - val scrollState = rememberVicoScrollState() - val zoomState = rememberVicoZoomState( - zoomEnabled = true, - initialZoom = Zoom.Content, // Zoom to fit content initially - ) + val startYAxis = if (showYAxis) VerticalAxis.rememberStart(valueFormatter = yAxisValueFormatter) else null + val endYAxis = if (showYAxis) VerticalAxis.rememberEnd(valueFormatter = yAxisValueFormatter) else null - val xAxisValueFormatter = rememberXAxisValueFormatter(X_TO_DATE_MAP_KEY, DATE_FORMATTER) - val yAxisValueFormatter = CartesianValueFormatter.decimal() // Standard decimal formatting for Y-axis - - // Conditionally create X-axis; hide if a specific targetMeasurementTypeId is set (for cleaner detail view). - val xAxis = if (targetMeasurementTypeId == null) { - HorizontalAxis.rememberBottom( - valueFormatter = xAxisValueFormatter, - guideline = null, // No guideline for X-axis for cleaner look - ) - } else { - null // Hide X-axis when showing a single, targeted measurement type - } - - val rangeProvider = remember { - object : CartesianLayerRangeProvider { - override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { - val r = maxY - minY - return if (r == 0.0) minY - 1.0 else floor(minY - 0.1 * r) + val lineProviderForStartAxis = remember(seriesEntriesForStartAxis, typeColorsForStartAxis, showDataPointsSetting, targetMeasurementTypeId) { + LineCartesianLayer.LineProvider.series( + seriesEntriesForStartAxis.mapIndexedNotNull { index, _ -> + if (index < typeColorsForStartAxis.size) { + createLineSpec(typeColorsForStartAxis[index], targetMeasurementTypeId != null, showDataPointsSetting) + } else null + } + ) } - override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { - val r = maxY - minY - return if (r == 0.0) maxY + 1.0 else ceil(maxY + 0.1 * r) + val lineLayerForStartAxis = if (seriesEntriesForStartAxis.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = lineProviderForStartAxis, + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = rangeProvider + ) + } else null + + val lineProviderForEndAxis = remember(seriesEntriesForEndAxis, typeColorsForEndAxis, showDataPointsSetting, targetMeasurementTypeId) { + LineCartesianLayer.LineProvider.series( + seriesEntriesForEndAxis.mapIndexedNotNull { index, _ -> + if (index < typeColorsForEndAxis.size) { + createLineSpec( + color = typeColorsForEndAxis[index], + statisticsMode = targetMeasurementTypeId != null, + showPoints = showDataPointsSetting + ) + } else null + } + ) } - } - } + val lineLayerForEndAxis = if (seriesEntriesForEndAxis.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = lineProviderForEndAxis, + verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = rangeProvider + ) + } else null - // Conditionally create Y-axis. - val startYAxis = if (showYAxis) { - VerticalAxis.rememberStart(valueFormatter = yAxisValueFormatter) - } else { null } - - val endYAxis = if (showYAxis) { - VerticalAxis.rememberEnd(valueFormatter = yAxisValueFormatter) - } else { null } - - val lineProviderForStartAxis = remember(seriesEntriesForStartAxis, typeColorsForStartAxis) { - LineCartesianLayer.LineProvider.series( - seriesEntriesForStartAxis.mapIndexedNotNull { index, _ -> - if (index < typeColorsForStartAxis.size) { - createLineSpec( - color = typeColorsForStartAxis[index], - statisticsMode = targetMeasurementTypeId != null, - showPoints = showDataPointsSetting - ) - } else null + val layers : List = remember(lineLayerForStartAxis, lineLayerForEndAxis) { + listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis) } - ) - } - val lineLayerForStartAxis = if (seriesEntriesForStartAxis.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = lineProviderForStartAxis, - verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = rangeProvider - ) - } else { - null - } - val lineProviderForEndAxis = remember(seriesEntriesForEndAxis, typeColorsForEndAxis) { - LineCartesianLayer.LineProvider.series( - seriesEntriesForEndAxis.mapIndexedNotNull { index, _ -> - if (index < typeColorsForEndAxis.size) { - createLineSpec( - color = typeColorsForEndAxis[index], - statisticsMode = targetMeasurementTypeId != null, - showPoints = showDataPointsSetting - ) - } else null + val lastX = remember { mutableStateOf(null) } + val markerVisibilityListener = remember(xToDatesMapForStore, onPointSelected) { + object : CartesianMarkerVisibilityListener { + override fun onShown(marker: CartesianMarker, targets: List) { + lastX.value = targets.lastOrNull()?.x?.toFloat() + } + override fun onUpdated(marker: CartesianMarker, targets: List) { + 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 lineLayerForEndAxis = if (seriesEntriesForEndAxis.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = lineProviderForEndAxis, - verticalAxisPosition = Axis.Position.Vertical.End, - rangeProvider = rangeProvider + + val chart = rememberCartesianChart( + layers = layers.toTypedArray(), + startAxis = startYAxis, + bottomAxis = xAxis, + endAxis = endYAxis, + marker = rememberMarker(), + markerVisibilityListener = markerVisibilityListener ) - } else { - null - } - val layers : List = remember(lineLayerForStartAxis, lineLayerForEndAxis) { - listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis) - } - - val lastX = remember { mutableStateOf(null) } - - val markerVisibilityListener = remember(xToDatesMapForStore) { - object : CartesianMarkerVisibilityListener { - override fun onShown(marker: CartesianMarker, targets: List) { - lastX.value = targets.lastOrNull()?.x?.toFloat() - } - override fun onUpdated(marker: CartesianMarker, targets: List) { - 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) - } + CartesianChartHost( + chart = chart, + modelProducer = modelProducer, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + scrollState = scrollState, + zoomState = zoomState + ) } } - - val chart = rememberCartesianChart( - layers = layers.toTypedArray(), - startAxis = startYAxis, // left Y-axis - bottomAxis = xAxis, // X-axis - endAxis = endYAxis, // right Y-axis - marker = rememberMarker(), // Interactive marker for data points - markerVisibilityListener = markerVisibilityListener - ) - - CartesianChartHost( - chart = chart, - modelProducer = modelProducer, - modifier = Modifier - .fillMaxWidth() - .weight(1f), // Occupy available vertical space - scrollState = scrollState, - zoomState = zoomState - ) } } + /** * Provides a [SharedViewModel.TopBarAction] for filtering the line chart. * This includes options for selecting the time range and toggling the visibility 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 46572cb3..43fedafc 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 @@ -427,133 +427,156 @@ fun OverviewScreen( sharedViewModel.setTopBarActions(actions) } - Column(modifier = Modifier.fillMaxSize()) { - if (selectedUserId == null) { + when { + // Case 1: Display a global loading indicator. + // This remains true as long as the ViewModel's overviewUiState is UiState.Loading. + overviewState is SharedViewModel.UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), // Occupies the entire available space + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + // Case 2: User restoration is complete (overviewState is not Loading), + // and no user is selected. + selectedUserId == null && overviewState !is SharedViewModel.UiState.Loading -> { Box( modifier = Modifier - .weight(1f) - .fillMaxSize(), + .fillMaxSize() // Occupies the entire available space + .padding(16.dp), contentAlignment = Alignment.Center ) { NoUserSelectedCard(navController = navController) } - return@Column } - when (val state = overviewState) { - SharedViewModel.UiState.Loading -> { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - is SharedViewModel.UiState.Error -> { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { - Text(state.message ?: stringResource(R.string.error_loading_data)) - } - } - - is SharedViewModel.UiState.Success -> { - val items = state.data - - if (items.isEmpty()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - NoMeasurementsCard( - navController = navController, - selectedUserId = selectedUserId - ) - } - } else { - val topId = items.firstOrNull()?.measurementWithValues?.measurement?.id - LaunchedEffect(topId) { - if (topId != null && !listState.isScrollInProgress) { - delay(60) - listState.smartScrollTo(0) - } - } - - // Chart - Box(modifier = Modifier.fillMaxWidth()) { - LineChart( - sharedViewModel = sharedViewModel, - screenContextName = SettingsPreferenceKeys.OVERVIEW_SCREEN_CONTEXT, - showFilterControls = true, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(bottom = 8.dp), - showYAxis = false, - onPointSelected = { selectedTs -> - val listForFind = items.map { it.measurementWithValues } - sharedViewModel.findClosestMeasurement(selectedTs, listForFind) - ?.let { (targetIndex, mwv) -> - val targetId = mwv.measurement.id - scope.launch { - listState.smartScrollTo( - index = targetIndex - ) - highlightedMeasurementId = targetId - delay(600) - if (highlightedMeasurementId == targetId) highlightedMeasurementId = - null - } - } + // Case 3: User restoration is complete (overviewState is not Loading), + // and a user IS selected. + selectedUserId != null && overviewState !is SharedViewModel.UiState.Loading -> { + Column(modifier = Modifier.fillMaxSize()) { + when (val state = overviewState) { + is SharedViewModel.UiState.Success -> { + val items = state.data + if (items.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) // Takes remaining space in the Column + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + NoMeasurementsCard( + navController = navController, + selectedUserId = selectedUserId // Not null here + ) + } + } else { + val topId = items.firstOrNull()?.measurementWithValues?.measurement?.id + LaunchedEffect(topId, items.size) { // items.size added as a key + if (topId != null && !listState.isScrollInProgress) { + delay(60) + listState.smartScrollTo(0) + } } - ) - } - HorizontalDivider() + // Chart + Box(modifier = Modifier.fillMaxWidth()) { + LineChart( + sharedViewModel = sharedViewModel, + screenContextName = SettingsPreferenceKeys.OVERVIEW_SCREEN_CONTEXT, + showFilterControls = true, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(bottom = 8.dp), + showYAxis = false, + onPointSelected = { selectedTs -> + val listForFind = items.map { it.measurementWithValues } + sharedViewModel.findClosestMeasurement(selectedTs, listForFind) + ?.let { (targetIndex, mwv) -> + val targetId = mwv.measurement.id + scope.launch { + listState.smartScrollTo( + index = targetIndex + ) + highlightedMeasurementId = targetId + delay(600) + if (highlightedMeasurementId == targetId) highlightedMeasurementId = + null + } + } + } + ) + } - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f) - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - itemsIndexed( - items = items, - key = { _, item -> item.measurementWithValues.measurement.id } - ) { _, enrichedItem -> - MeasurementCard( - sharedViewModel = sharedViewModel, - measurementWithValues = enrichedItem.measurementWithValues, - processedValuesForDisplay = enrichedItem.valuesWithTrend, - userEvaluationContext = userEvalContext, - onEdit = { - navController.navigate( - Routes.measurementDetail( - enrichedItem.measurementWithValues.measurement.id, - selectedUserId!! - ) + HorizontalDivider() + + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) // Takes remaining space in the Column + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed( + items = items, + key = { _, item -> item.measurementWithValues.measurement.id } + ) { _, enrichedItem -> + MeasurementCard( + sharedViewModel = sharedViewModel, + measurementWithValues = enrichedItem.measurementWithValues, + processedValuesForDisplay = enrichedItem.valuesWithTrend, + userEvaluationContext = userEvalContext, // Ensure this is available + onEdit = { + navController.navigate( + Routes.measurementDetail( + enrichedItem.measurementWithValues.measurement.id, + selectedUserId!! // Not null here + ) + ) + }, + onDelete = { + sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement) + }, + isHighlighted = (highlightedMeasurementId == enrichedItem.measurementWithValues.measurement.id) ) - }, - onDelete = { - sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement) - }, - isHighlighted = (highlightedMeasurementId == enrichedItem.measurementWithValues.measurement.id) - ) + } + } + } + } + is SharedViewModel.UiState.Error -> { + Box( + modifier = Modifier + .weight(1f) // Takes remaining space in the Column + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(state.message ?: stringResource(R.string.error_loading_data)) + } + } + SharedViewModel.UiState.Loading -> { + // This case should ideally not be reached if the outer 'when' condition is met, + // or only very briefly if data for a specific user is loading. + Box( + modifier = Modifier + .weight(1f) // Takes remaining space in the Column + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } } } } + else -> { + // Fallback for any unhandled state combination. + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // Occupies the entire available space + Text("Unexpected state") + } + } } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index ba566678..649a22f4 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -123,6 +123,7 @@ class SharedViewModel @Inject constructor( } private val didRunDerivedBackfill = AtomicBoolean(false) + private val _isInitialUserLoadComplete = MutableStateFlow(false) // --- Users (via UserFacade) --- val allUsers: StateFlow> = @@ -153,32 +154,62 @@ class SharedViewModel @Inject constructor( } val overviewUiState: StateFlow>> = - selectedUserId.flatMapLatest { uid -> - if (uid == null) flowOf(UiState.Success(emptyList())) - else measurementFacade.enrichedFlowForUser(uid, measurementTypes) - .map< List, UiState> > { UiState.Success(it) } - .onStart { emit(UiState.Loading) } - .catch { emit(UiState.Error(it.message)) } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) + _isInitialUserLoadComplete + .flatMapLatest { initialAttemptDone -> + if (!initialAttemptDone) { + flowOf(UiState.Loading) + } else { + userFacade.observeSelectedUserId().flatMapLatest { uidFromFacade -> + if (uidFromFacade == null) { + flowOf(UiState.Success(emptyList())) + } else { + measurementFacade.enrichedFlowForUser(uidFromFacade, measurementTypes) + .map, UiState>> { + UiState.Success(it) + } + .onStart { emit(UiState.Loading) } + .catch { emit(UiState.Error(it.message)) } + } + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.Loading + ) val graphUiState: StateFlow>> = - selectedUserId - .flatMapLatest { uid -> - if (uid == null) flowOf(UiState.Success(emptyList())) - else measurementFacade.pipeline( - userId = uid, - measurementTypesFlow = measurementTypes, - timeRangeFlow = selectedTimeRange, - typesToSmoothFlow = typesToSmoothAndDisplay, - algorithmFlow = selectedSmoothingAlgorithm, - alphaFlow = smoothingAlpha, - windowFlow = smoothingWindowSize - ) - .map< List, UiState> > { UiState.Success(it) } - .onStart { emit(UiState.Loading) } - .catch { emit(UiState.Error(it.message)) } - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading) + _isInitialUserLoadComplete + .flatMapLatest { initialAttemptDone -> + if (!initialAttemptDone) { + flowOf(UiState.Loading) + } else { + userFacade.observeSelectedUserId().flatMapLatest { uidFromFacade -> + if (uidFromFacade == null) { + flowOf(UiState.Success(emptyList())) + } else { + measurementFacade.pipeline( + userId = uidFromFacade, + measurementTypesFlow = measurementTypes, + timeRangeFlow = selectedTimeRange, + typesToSmoothFlow = typesToSmoothAndDisplay, + algorithmFlow = selectedSmoothingAlgorithm, + alphaFlow = smoothingAlpha, + windowFlow = smoothingWindowSize + ) + .map, UiState>> { + UiState.Success(it) + } + .onStart { emit(UiState.Loading) } + .catch { emit(UiState.Error(it.message)) } + } + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = UiState.Loading + ) fun statisticsUiState( range: TimeRangeFilter @@ -314,6 +345,7 @@ class SharedViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { userFacade.restoreOrSelectDefaultUser() .onFailure { LogManager.e(TAG, "Failed to restore/select default user: ${it.message}") } + .also { _isInitialUserLoadComplete.value = true } maybeBackfillDerivedValues() }