1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-10-27 14:01:24 +01:00

Resolved an issue causing the "No User Selected" card (or data views) to flicker briefly on app start or when the initial user was being determined.

The `overviewUiState` and `graphUiState` now correctly waits for the `_initialUserLoadComplete` signal before attempting to display user-specific data or the empty user state. This ensures a proper loading indicator is shown until the user context is initialized, preventing premature rendering of incorrect states.
This commit is contained in:
oliexdev
2025-09-09 19:30:49 +02:00
parent 0e7fcc4a08
commit 855e096ab9
3 changed files with 420 additions and 375 deletions

View File

@@ -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<EnrichedMeasurement>() }
val smoothedData by sharedViewModel
.smoothedEnrichedMeasurements(
timeRangeFlow = timeRangeFlow,
typesToSmoothAndDisplayFlow = typesToSmoothFlow
)
.collectAsStateWithLifecycle(initialValue = emptyList<EnrichedMeasurement>())
.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<LineCartesianLayer> = 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<Float?>(null) }
val markerVisibilityListener = remember(xToDatesMapForStore, onPointSelected) {
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
lastX.value = targets.lastOrNull()?.x?.toFloat()
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
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<LineCartesianLayer> = remember(lineLayerForStartAxis, lineLayerForEndAxis) {
listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis)
}
val lastX = remember { mutableStateOf<Float?>(null) }
val markerVisibilityListener = remember(xToDatesMapForStore) {
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
lastX.value = targets.lastOrNull()?.x?.toFloat()
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
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

View File

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

View File

@@ -123,6 +123,7 @@ class SharedViewModel @Inject constructor(
}
private val didRunDerivedBackfill = AtomicBoolean(false)
private val _isInitialUserLoadComplete = MutableStateFlow(false)
// --- Users (via UserFacade) ---
val allUsers: StateFlow<List<User>> =
@@ -153,32 +154,62 @@ class SharedViewModel @Inject constructor(
}
val overviewUiState: StateFlow<UiState<List<EnrichedMeasurement>>> =
selectedUserId.flatMapLatest { uid ->
if (uid == null) flowOf(UiState.Success(emptyList()))
else measurementFacade.enrichedFlowForUser(uid, measurementTypes)
.map< List<EnrichedMeasurement>, UiState<List<EnrichedMeasurement>> > { 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<List<EnrichedMeasurement>, UiState<List<EnrichedMeasurement>>> {
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<UiState<List<EnrichedMeasurement>>> =
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<EnrichedMeasurement>, UiState<List<EnrichedMeasurement>> > { 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<List<EnrichedMeasurement>, UiState<List<EnrichedMeasurement>>> {
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()
}