1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-10-28 14:25:17 +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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Check
import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -143,6 +145,7 @@ fun LineChart(
targetMeasurementTypeId: Int? = null, targetMeasurementTypeId: Int? = null,
onPointSelected: (timestamp: Long) -> Unit = {} onPointSelected: (timestamp: Long) -> Unit = {}
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val showTypeFilterRowSetting by rememberContextualBooleanSetting( val showTypeFilterRowSetting by rememberContextualBooleanSetting(
@@ -190,22 +193,30 @@ fun LineChart(
currentSelectedTypeIdsStrings.mapNotNull { stringId: String -> stringId.toIntOrNull() }.toSet() 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) } val timeRangeFlow = remember { MutableStateFlow(uiSelectedTimeRange) }
LaunchedEffect(uiSelectedTimeRange) { LaunchedEffect(uiSelectedTimeRange) { timeRangeFlow.value = uiSelectedTimeRange }
timeRangeFlow.value = uiSelectedTimeRange
}
val typesToSmoothFlow = remember { MutableStateFlow(currentSelectedTypeIntIds) } val typesToSmoothFlow = remember { MutableStateFlow(currentSelectedTypeIntIds) }
LaunchedEffect(currentSelectedTypeIntIds) { LaunchedEffect(currentSelectedTypeIntIds) { typesToSmoothFlow.value = 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 val smoothedData by sharedViewModel
.smoothedEnrichedMeasurements( .smoothedEnrichedMeasurements(
timeRangeFlow = timeRangeFlow, timeRangeFlow = timeRangeFlow,
typesToSmoothAndDisplayFlow = typesToSmoothFlow 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) { val fullyFilteredEnrichedMeasurements = remember(smoothedData, currentSelectedTypeIntIds) {
sharedViewModel.filterEnrichedMeasurementsByTypes(smoothedData, currentSelectedTypeIntIds) sharedViewModel.filterEnrichedMeasurementsByTypes(smoothedData, currentSelectedTypeIntIds)
@@ -278,7 +289,6 @@ fun LineChart(
) )
} }
if (showFilterTitle) { if (showFilterTitle) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -296,7 +306,7 @@ fun LineChart(
Text( Text(
text = stringResource( text = stringResource(
R.string.line_chart_filter_title_template, R.string.line_chart_filter_title_template,
uiSelectedTimeRange.getDisplayName(LocalContext.current), uiSelectedTimeRange.getDisplayName(context),
measurementsWithValues.size measurementsWithValues.size
), ),
style = MaterialTheme.typography.bodyMedium, 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) var showNoDataMessage by remember { mutableStateOf(false) }
// This is a general "empty state" for the chart area. var noDataMessageText by remember { mutableStateOf("") }
if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && !effectiveShowTypeFilterRow && targetMeasurementTypeId == null) {
Box( LaunchedEffect(
modifier = Modifier isChartDataLoading, lineTypesToActuallyPlot, measurementsWithValues,
.weight(1f) // Takes up available vertical space in the Column effectiveShowTypeFilterRow, targetMeasurementTypeId, allAvailableMeasurementTypes
.fillMaxWidth(), ) {
contentAlignment = Alignment.Center if (!isChartDataLoading) {
) { if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && !effectiveShowTypeFilterRow && targetMeasurementTypeId == null) {
Text( showNoDataMessage = true
// Provide a more specific message if no types are plottable at all. noDataMessageText = if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) })
if (allAvailableMeasurementTypes.none { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) }) context.getString(R.string.line_chart_no_plottable_types)
stringResource(R.string.line_chart_no_plottable_types) else context.getString(R.string.line_chart_no_data_to_display)
else stringResource(R.string.line_chart_no_data_to_display) } else if (lineTypesToActuallyPlot.isEmpty() && measurementsWithValues.isEmpty() && targetMeasurementTypeId != null) {
) showNoDataMessage = true
} noDataMessageText = context.getString(
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(
R.string.line_chart_no_data_for_type_in_range, R.string.line_chart_no_data_for_type_in_range,
allAvailableMeasurementTypes.find { it.id == targetMeasurementTypeId }?.getDisplayName(LocalContext.current) allAvailableMeasurementTypes.find { it.id == targetMeasurementTypeId }?.getDisplayName(context)
?: stringResource(R.string.line_chart_this_type_placeholder) ?: context.getString(R.string.line_chart_this_type_placeholder)
), )
style = MaterialTheme.typography.bodyMedium, } else {
color = MaterialTheme.colorScheme.onSurfaceVariant, showNoDataMessage = false
textAlign = TextAlign.Center }
) } else {
showNoDataMessage = false
} }
return@Column
} }
// State to hold the processed series data for the chart. // 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). when {
if (seriesEntries.isEmpty()) { isChartDataLoading -> {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) // Takes up available vertical space .weight(1f)
.fillMaxWidth(), .fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val message = if (lineTypesToActuallyPlot.isEmpty() && effectiveShowTypeFilterRow) { CircularProgressIndicator()
// 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)
} }
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) { val seriesEntriesForEndAxis = remember(seriesEntries) {
seriesEntries.filter { (type, _) -> seriesEntries.filter { (type, _) -> type.isOnRightYAxis }
!type.isOnRightYAxis }
} val typeColorsForEndAxis = remember(seriesEntriesForEndAxis) {
} seriesEntriesForEndAxis.map { (type, _) -> if (type.color != 0) Color(type.color) else Color.Gray }
val typeColorsForStartAxis = remember(seriesEntriesForStartAxis) { }
seriesEntriesForStartAxis.map { (type, _) ->
if (type.color != 0) Color(type.color) else Color.Gray
}
}
val seriesEntriesForEndAxis = remember(seriesEntries) { val modelProducer = remember { CartesianChartModelProducer() }
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() } LaunchedEffect(seriesEntriesForStartAxis, seriesEntriesForEndAxis, xToDatesMapForStore) {
if (seriesEntriesForStartAxis.isNotEmpty() || seriesEntriesForEndAxis.isNotEmpty()) {
LaunchedEffect(seriesEntriesForStartAxis, seriesEntriesForEndAxis, xToDatesMapForStore) { modelProducer.runTransaction {
if (seriesEntriesForStartAxis.isNotEmpty() || seriesEntriesForEndAxis.isNotEmpty()) { if (seriesEntriesForStartAxis.isNotEmpty()) {
modelProducer.runTransaction { lineSeries {
if (seriesEntriesForStartAxis.isNotEmpty()) { seriesEntriesForStartAxis.forEach { (_, sortedDateValuePairs) ->
lineSeries { val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() }
seriesEntriesForStartAxis.forEach { (_, sortedDateValuePairs) -> val yValues = sortedDateValuePairs.map { it.second }
val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } if (xValues.isNotEmpty()) series(x = xValues, y = yValues)
val yValues = sortedDateValuePairs.map { it.second } }
if (xValues.isNotEmpty()) {
series(x = xValues, y = yValues)
} }
} }
} if (seriesEntriesForEndAxis.isNotEmpty()) {
} lineSeries {
seriesEntriesForEndAxis.forEach { (_, sortedDateValuePairs) ->
if (seriesEntriesForEndAxis.isNotEmpty()) { val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() }
lineSeries { val yValues = sortedDateValuePairs.map { it.second }
seriesEntriesForEndAxis.forEach { (_, sortedDateValuePairs) -> if (xValues.isNotEmpty()) series(x = xValues, y = yValues)
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 { val scrollState = rememberVicoScrollState()
lineSeries {} val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content)
lineSeries {} val xAxisValueFormatter = rememberXAxisValueFormatter(X_TO_DATE_MAP_KEY, DATE_FORMATTER)
extras { it.remove(X_TO_DATE_MAP_KEY) } 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 startYAxis = if (showYAxis) VerticalAxis.rememberStart(valueFormatter = yAxisValueFormatter) else null
val zoomState = rememberVicoZoomState( val endYAxis = if (showYAxis) VerticalAxis.rememberEnd(valueFormatter = yAxisValueFormatter) else null
zoomEnabled = true,
initialZoom = Zoom.Content, // Zoom to fit content initially
)
val xAxisValueFormatter = rememberXAxisValueFormatter(X_TO_DATE_MAP_KEY, DATE_FORMATTER) val lineProviderForStartAxis = remember(seriesEntriesForStartAxis, typeColorsForStartAxis, showDataPointsSetting, targetMeasurementTypeId) {
val yAxisValueFormatter = CartesianValueFormatter.decimal() // Standard decimal formatting for Y-axis LineCartesianLayer.LineProvider.series(
seriesEntriesForStartAxis.mapIndexedNotNull { index, _ ->
// Conditionally create X-axis; hide if a specific targetMeasurementTypeId is set (for cleaner detail view). if (index < typeColorsForStartAxis.size) {
val xAxis = if (targetMeasurementTypeId == null) { createLineSpec(typeColorsForStartAxis[index], targetMeasurementTypeId != null, showDataPointsSetting)
HorizontalAxis.rememberBottom( } else null
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)
} }
override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { val lineLayerForStartAxis = if (seriesEntriesForStartAxis.isNotEmpty()) {
val r = maxY - minY rememberLineCartesianLayer(
return if (r == 0.0) maxY + 1.0 else ceil(maxY + 0.1 * r) 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 layers : List<LineCartesianLayer> = remember(lineLayerForStartAxis, lineLayerForEndAxis) {
val startYAxis = if (showYAxis) { listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis)
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 lineLayerForStartAxis = if (seriesEntriesForStartAxis.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = lineProviderForStartAxis,
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = rangeProvider
)
} else {
null
}
val lineProviderForEndAxis = remember(seriesEntriesForEndAxis, typeColorsForEndAxis) { val lastX = remember { mutableStateOf<Float?>(null) }
LineCartesianLayer.LineProvider.series( val markerVisibilityListener = remember(xToDatesMapForStore, onPointSelected) {
seriesEntriesForEndAxis.mapIndexedNotNull { index, _ -> object : CartesianMarkerVisibilityListener {
if (index < typeColorsForEndAxis.size) { override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
createLineSpec( lastX.value = targets.lastOrNull()?.x?.toFloat()
color = typeColorsForEndAxis[index], }
statisticsMode = targetMeasurementTypeId != null, override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
showPoints = showDataPointsSetting lastX.value = targets.lastOrNull()?.x?.toFloat()
) }
} else null 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 chart = rememberCartesianChart(
val lineLayerForEndAxis = if (seriesEntriesForEndAxis.isNotEmpty()) { layers = layers.toTypedArray(),
rememberLineCartesianLayer( startAxis = startYAxis,
lineProvider = lineProviderForEndAxis, bottomAxis = xAxis,
verticalAxisPosition = Axis.Position.Vertical.End, endAxis = endYAxis,
rangeProvider = rangeProvider marker = rememberMarker(),
markerVisibilityListener = markerVisibilityListener
) )
} else {
null
}
val layers : List<LineCartesianLayer> = remember(lineLayerForStartAxis, lineLayerForEndAxis) { CartesianChartHost(
listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis) chart = chart,
} modelProducer = modelProducer,
modifier = Modifier
val lastX = remember { mutableStateOf<Float?>(null) } .fillMaxWidth()
.weight(1f),
val markerVisibilityListener = remember(xToDatesMapForStore) { scrollState = scrollState,
object : CartesianMarkerVisibilityListener { zoomState = zoomState
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 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. * Provides a [SharedViewModel.TopBarAction] for filtering the line chart.
* This includes options for selecting the time range and toggling the visibility * This includes options for selecting the time range and toggling the visibility

View File

@@ -427,133 +427,156 @@ fun OverviewScreen(
sharedViewModel.setTopBarActions(actions) sharedViewModel.setTopBarActions(actions)
} }
Column(modifier = Modifier.fillMaxSize()) { when {
if (selectedUserId == null) { // 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( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxSize() // Occupies the entire available space
.fillMaxSize(), .padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
NoUserSelectedCard(navController = navController) NoUserSelectedCard(navController = navController)
} }
return@Column
} }
when (val state = overviewState) { // Case 3: User restoration is complete (overviewState is not Loading),
SharedViewModel.UiState.Loading -> { // and a user IS selected.
Box( selectedUserId != null && overviewState !is SharedViewModel.UiState.Loading -> {
modifier = Modifier Column(modifier = Modifier.fillMaxSize()) {
.fillMaxWidth() when (val state = overviewState) {
.height(200.dp), is SharedViewModel.UiState.Success -> {
contentAlignment = Alignment.Center val items = state.data
) { if (items.isEmpty()) {
CircularProgressIndicator() Box(
} modifier = Modifier
} .weight(1f) // Takes remaining space in the Column
.fillMaxSize(),
is SharedViewModel.UiState.Error -> { contentAlignment = Alignment.Center
Box( ) {
modifier = Modifier NoMeasurementsCard(
.fillMaxWidth() navController = navController,
.height(200.dp), selectedUserId = selectedUserId // Not null here
contentAlignment = Alignment.Center )
) { }
Text(state.message ?: stringResource(R.string.error_loading_data)) } else {
} val topId = items.firstOrNull()?.measurementWithValues?.measurement?.id
} LaunchedEffect(topId, items.size) { // items.size added as a key
if (topId != null && !listState.isScrollInProgress) {
is SharedViewModel.UiState.Success -> { delay(60)
val items = state.data listState.smartScrollTo(0)
}
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
}
}
} }
)
}
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( HorizontalDivider()
state = listState,
modifier = Modifier LazyColumn(
.weight(1f) state = listState,
.fillMaxSize() modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp), .weight(1f) // Takes remaining space in the Column
verticalArrangement = Arrangement.spacedBy(12.dp) .fillMaxSize()
) { .padding(horizontal = 16.dp, vertical = 8.dp),
itemsIndexed( verticalArrangement = Arrangement.spacedBy(12.dp)
items = items, ) {
key = { _, item -> item.measurementWithValues.measurement.id } itemsIndexed(
) { _, enrichedItem -> items = items,
MeasurementCard( key = { _, item -> item.measurementWithValues.measurement.id }
sharedViewModel = sharedViewModel, ) { _, enrichedItem ->
measurementWithValues = enrichedItem.measurementWithValues, MeasurementCard(
processedValuesForDisplay = enrichedItem.valuesWithTrend, sharedViewModel = sharedViewModel,
userEvaluationContext = userEvalContext, measurementWithValues = enrichedItem.measurementWithValues,
onEdit = { processedValuesForDisplay = enrichedItem.valuesWithTrend,
navController.navigate( userEvaluationContext = userEvalContext, // Ensure this is available
Routes.measurementDetail( onEdit = {
enrichedItem.measurementWithValues.measurement.id, navController.navigate(
selectedUserId!! 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 didRunDerivedBackfill = AtomicBoolean(false)
private val _isInitialUserLoadComplete = MutableStateFlow(false)
// --- Users (via UserFacade) --- // --- Users (via UserFacade) ---
val allUsers: StateFlow<List<User>> = val allUsers: StateFlow<List<User>> =
@@ -153,32 +154,62 @@ class SharedViewModel @Inject constructor(
} }
val overviewUiState: StateFlow<UiState<List<EnrichedMeasurement>>> = val overviewUiState: StateFlow<UiState<List<EnrichedMeasurement>>> =
selectedUserId.flatMapLatest { uid -> _isInitialUserLoadComplete
if (uid == null) flowOf(UiState.Success(emptyList())) .flatMapLatest { initialAttemptDone ->
else measurementFacade.enrichedFlowForUser(uid, measurementTypes) if (!initialAttemptDone) {
.map< List<EnrichedMeasurement>, UiState<List<EnrichedMeasurement>> > { UiState.Success(it) } flowOf(UiState.Loading)
.onStart { emit(UiState.Loading) } } else {
.catch { emit(UiState.Error(it.message)) } userFacade.observeSelectedUserId().flatMapLatest { uidFromFacade ->
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) 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>>> = val graphUiState: StateFlow<UiState<List<EnrichedMeasurement>>> =
selectedUserId _isInitialUserLoadComplete
.flatMapLatest { uid -> .flatMapLatest { initialAttemptDone ->
if (uid == null) flowOf(UiState.Success(emptyList())) if (!initialAttemptDone) {
else measurementFacade.pipeline( flowOf(UiState.Loading)
userId = uid, } else {
measurementTypesFlow = measurementTypes, userFacade.observeSelectedUserId().flatMapLatest { uidFromFacade ->
timeRangeFlow = selectedTimeRange, if (uidFromFacade == null) {
typesToSmoothFlow = typesToSmoothAndDisplay, flowOf(UiState.Success(emptyList()))
algorithmFlow = selectedSmoothingAlgorithm, } else {
alphaFlow = smoothingAlpha, measurementFacade.pipeline(
windowFlow = smoothingWindowSize userId = uidFromFacade,
) measurementTypesFlow = measurementTypes,
.map< List<EnrichedMeasurement>, UiState<List<EnrichedMeasurement>> > { UiState.Success(it) } timeRangeFlow = selectedTimeRange,
.onStart { emit(UiState.Loading) } typesToSmoothFlow = typesToSmoothAndDisplay,
.catch { emit(UiState.Error(it.message)) } algorithmFlow = selectedSmoothingAlgorithm,
} alphaFlow = smoothingAlpha,
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading) 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( fun statisticsUiState(
range: TimeRangeFilter range: TimeRangeFilter
@@ -314,6 +345,7 @@ class SharedViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userFacade.restoreOrSelectDefaultUser() userFacade.restoreOrSelectDefaultUser()
.onFailure { LogManager.e(TAG, "Failed to restore/select default user: ${it.message}") } .onFailure { LogManager.e(TAG, "Failed to restore/select default user: ${it.message}") }
.also { _isInitialUserLoadComplete.value = true }
maybeBackfillDerivedValues() maybeBackfillDerivedValues()
} }