From 3a13162f9ebe43b1a12c2d360eb26300b02f9c64 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Mon, 18 Aug 2025 18:12:27 +0200 Subject: [PATCH] =?UTF-8?q?Displays=20evaluation=20state=20symbols=20(?= =?UTF-8?q?=E2=96=B2,=20=E2=96=BC,=20=E2=97=8F,=20!)=20next=20to=20values?= =?UTF-8?q?=20in=20table=20screen,=20similar=20to=20the=20overview=20scree?= =?UTF-8?q?n.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/eval/MeasurementEvaluator.kt | 20 + .../ui/screen/overview/OverviewScreen.kt | 28 +- .../openscale/ui/screen/table/TableScreen.kt | 554 ++++++++++-------- 3 files changed, 327 insertions(+), 275 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt b/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt index 504698d3..47fe5562 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt @@ -81,6 +81,26 @@ object MeasurementEvaluator { else -> null } } + + /** + * Returns a broad **plausible** percentage range for selected measurement types. + * + * This is **not** a clinical reference band. It’s only used to catch obviously + * incorrect values (e.g., sensor glitches, unit mix-ups) before attempting + * a proper evaluation. The ranges are intentionally wide. + * + * @param typeKey The measurement type to check. + * @return A closed percent range [min .. max] if the metric is percentage-based and supported, + * or `null` if no generic plausibility range is defined for this type. + */ + fun plausiblePercentRangeFor(typeKey: MeasurementTypeKey): ClosedFloatingPointRange? = + when (typeKey) { + MeasurementTypeKey.WATER -> 35f..75f + MeasurementTypeKey.BODY_FAT -> 3f..70f + MeasurementTypeKey.MUSCLE -> 15f..60f + else -> null + } + // --- Body composition --- fun evalBodyFat(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = 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 b015de48..4c959654 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 @@ -91,7 +91,6 @@ import androidx.navigation.NavController import com.health.openscale.R import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.InputFieldType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.Trend import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.database.UserPreferenceKeys @@ -880,7 +879,7 @@ fun MeasurementValueRow( val noAgeBand: Boolean = evalResult?.let { it.lowLimit < 0f || it.highLimit < 0f } ?: false // Flag 2: percent outside a plausible range (0..100) - val plausible = plausiblePercentRangeFor(type.key) + val plausible = MeasurementEvaluator.plausiblePercentRangeFor(type.key) val outOfPlausibleRange = if (numeric == null) { false @@ -1027,27 +1026,6 @@ private fun EvaluationErrorBanner(message: String) { } } - -/** - * Returns a broad **plausible** percentage range for selected measurement types. - * - * This is **not** a clinical reference band. It’s only used to catch obviously - * incorrect values (e.g., sensor glitches, unit mix-ups) before attempting - * a proper evaluation. The ranges are intentionally wide. - * - * @param typeKey The measurement type to check. - * @return A closed percent range [min .. max] if the metric is percentage-based and supported, - * or `null` if no generic plausibility range is defined for this type. - */ -private fun plausiblePercentRangeFor(typeKey: MeasurementTypeKey): ClosedFloatingPointRange? = - when (typeKey) { - MeasurementTypeKey.WATER -> 35f..75f - MeasurementTypeKey.BODY_FAT -> 3f..70f - MeasurementTypeKey.MUSCLE -> 15f..60f - else -> null - } - - /** * One measurement row that can expand to show a gauge or an info banner. * @@ -1101,7 +1079,7 @@ fun MeasurementRowExpandable( // 2) Implausible value for percentage-based metrics val unitName = type.unit.displayName - val plausible = plausiblePercentRangeFor(type.key) + val plausible = MeasurementEvaluator.plausiblePercentRangeFor(type.key) val outOfPlausibleRange = if (numeric == null) { false @@ -1144,7 +1122,7 @@ fun MeasurementRowExpandable( ) } outOfPlausibleRange -> { - val plausible = plausiblePercentRangeFor(type.key) ?: (0f..100f) + val plausible = MeasurementEvaluator.plausiblePercentRangeFor(type.key) ?: (0f..100f) EvaluationErrorBanner( message = stringResource( R.string.eval_out_of_plausible_range_percent, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt index 8c838332..ed31e94e 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt @@ -15,8 +15,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.health.openscale.ui.screen.components // Using package from the provided code +package com.health.openscale.ui.screen.components +import android.R.attr.type import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -33,6 +34,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -54,6 +56,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -65,6 +68,8 @@ import com.health.openscale.R import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.Trend +import com.health.openscale.core.eval.MeasurementEvaluator +import com.health.openscale.core.data.EvaluationState import com.health.openscale.ui.navigation.Routes import com.health.openscale.ui.screen.SharedViewModel import kotlinx.coroutines.launch @@ -73,14 +78,7 @@ import java.util.Date import java.util.Locale /** - * Represents the data for a single cell in the table, excluding the date cell. - * - * @property typeId The ID of the measurement type this cell data represents. - * @property displayValue The formatted string value to display in the cell. - * @property unit The unit of the measurement. - * @property difference The difference from the previous measurement of the same type, if applicable. - * @property trend The trend (up, down, none, not applicable) compared to the previous measurement. - * @property originalInputType The original [InputFieldType] of the measurement. + * Data for a single (non-date) table cell. */ data class TableCellData( val typeId: Int, @@ -88,17 +86,13 @@ data class TableCellData( val unit: String, val difference: Float? = null, val trend: Trend = Trend.NOT_APPLICABLE, - val originalInputType: InputFieldType + val originalInputType: InputFieldType, + val evalState: EvaluationState? = null, // computed evaluation state + val flagged: Boolean = false // true => show "!" in error color ) /** - * Represents the internal data structure for a single row in the table. - * - * @property measurementId The unique ID of the measurement this row corresponds to. - * @property timestamp The timestamp of the measurement. - * @property formattedTimestamp The formatted date and time string for display. - * @property values A map where the key is the measurement type ID (`typeId`) and the value - * is the [TableCellData] for that type in this row. + * A single row in the table. */ data class TableRowDataInternal( val measurementId: Int, @@ -108,15 +102,7 @@ data class TableRowDataInternal( ) /** - * Composable screen that displays measurement data in a tabular format. - * - * The table shows a fixed date column and scrollable columns for selected measurement types. - * Each cell can display the measured value and its trend/difference compared to the previous one. - * Users can filter which measurement types are displayed as columns. - * Tapping on a row navigates to the detailed view of that measurement. - * - * @param navController The NavController for navigation. - * @param sharedViewModel The [SharedViewModel] providing measurement data, types, and UI state. + * Table of measurements with a fixed date column and horizontally scrollable value columns. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -128,72 +114,109 @@ fun TableScreen( val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState() val isLoading by sharedViewModel.isBaseDataLoading.collectAsState() val allAvailableTypesFromVM by sharedViewModel.measurementTypes.collectAsState() + val userEvaluationContext by sharedViewModel.userEvaluationContext.collectAsState() - // Holds the IDs of columns selected by the user via the filter row. + // Column selection state provided by filter row. val selectedColumnIdsFromFilter = remember { mutableStateListOf() } - // Determines the actual measurement types to display as columns based on user selection. val displayedTypes = remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) { - allAvailableTypesFromVM.filter { type -> - type.id in selectedColumnIdsFromFilter - } + allAvailableTypesFromVM.filter { it.id in selectedColumnIdsFromFilter } } - // Transforms enriched measurements into a list of TableRowDataInternal for easier rendering. - val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM) { + // Transform enriched measurements -> table rows (compute eval state here). + val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM, userEvaluationContext) { if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) { emptyList() } else { - // Date formatter for the timestamp column. - val dateFormatter = SimpleDateFormat("E, dd.MM.yy HH:mm", Locale.getDefault()) + val dateFormatterDate = SimpleDateFormat.getDateInstance( + SimpleDateFormat.MEDIUM, + Locale.getDefault() + ) + val dateFormatterTime = SimpleDateFormat.getTimeInstance( + SimpleDateFormat.SHORT, + Locale.getDefault() + ) - enrichedMeasurements.map { enrichedItem -> // enrichedItem is EnrichedMeasurement - val cellValues = displayedTypes.associate { colType -> // Iterate over sorted, displayed types + enrichedMeasurements.map { enrichedItem -> + val ts = enrichedItem.measurementWithValues.measurement.timestamp + + val cellValues = displayedTypes.associate { colType -> val typeId = colType.id - // Find the corresponding value with trend from the enrichedItem val valueWithTrend = enrichedItem.valuesWithTrend.find { it.currentValue.type.id == typeId } if (valueWithTrend != null) { - val originalMeasurementValue = valueWithTrend.currentValue.value // This is MeasurementValue - val actualType = valueWithTrend.currentValue.type // This is MeasurementType + val originalMeasurementValue = valueWithTrend.currentValue.value + val actualType = valueWithTrend.currentValue.type val displayValueStr = when (actualType.inputType) { InputFieldType.FLOAT -> originalMeasurementValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) } ?: "-" InputFieldType.INT -> originalMeasurementValue.intValue?.toString() ?: "-" InputFieldType.TEXT -> originalMeasurementValue.textValue ?: "-" - // Add other InputFieldTypes here if needed (DATE, TIME etc.) - else -> originalMeasurementValue.textValue ?: originalMeasurementValue.floatValue?.toString() ?: originalMeasurementValue.intValue?.toString() ?: "-" + else -> originalMeasurementValue.textValue + ?: originalMeasurementValue.floatValue?.toString() + ?: originalMeasurementValue.intValue?.toString() + ?: "-" } + val unitStr = if (displayValueStr != "-") actualType.unit.displayName else "" + // --- Compute evaluation state and flags (like in Overview) --- + val numeric: Float? = when (actualType.inputType) { + InputFieldType.FLOAT -> originalMeasurementValue.floatValue + InputFieldType.INT -> originalMeasurementValue.intValue?.toFloat() + else -> null + } + + val ctx = userEvaluationContext + val evalResult = if (ctx != null && numeric != null) { + MeasurementEvaluator.evaluate( + typeKey = actualType.key, + value = numeric, + userEvaluationContext = ctx, + measuredAtMillis = ts + ) + } else null + + val noAgeBand = evalResult?.let { it.lowLimit < 0f || it.highLimit < 0f } ?: false + val plausible = MeasurementEvaluator.plausiblePercentRangeFor(actualType.key) + val outOfPlausibleRange = + if (numeric == null) { + false + } else { + plausible?.let { numeric < it.start || numeric > it.endInclusive } + ?: (unitStr == "%" && (numeric < 0f || numeric > 100f)) + } + typeId to TableCellData( typeId = typeId, displayValue = displayValueStr, unit = unitStr, - difference = valueWithTrend.difference, // Use directly - trend = valueWithTrend.trend, // Use directly - originalInputType = actualType.inputType + difference = valueWithTrend.difference, + trend = valueWithTrend.trend, + originalInputType = actualType.inputType, + evalState = evalResult?.state, + flagged = noAgeBand || outOfPlausibleRange ) } else { - // Fallback: No value for this type in this specific measurement - // (e.g., if the type was not measured). - // Use colType (the type from the column definition) for default info. typeId to TableCellData( typeId = typeId, displayValue = "-", - unit = colType.unit.displayName, // Show unit even if no value, for consistency + unit = colType.unit.displayName, difference = null, trend = Trend.NOT_APPLICABLE, - originalInputType = colType.inputType + originalInputType = colType.inputType, + evalState = null, + flagged = false ) } } + TableRowDataInternal( measurementId = enrichedItem.measurementWithValues.measurement.id, - timestamp = enrichedItem.measurementWithValues.measurement.timestamp, - formattedTimestamp = dateFormatter.format(Date(enrichedItem.measurementWithValues.measurement.timestamp)), - values = cellValues // cellValues is already Map + timestamp = ts, + formattedTimestamp = dateFormatterDate.format(Date(ts)) + "\n" + dateFormatterTime.format(Date(ts)), + values = cellValues ) } } @@ -206,31 +229,25 @@ fun TableScreen( val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection) val dateColumnHeader = stringResource(id = R.string.table_header_date) - LaunchedEffect(Unit, tableScreenTitle) { sharedViewModel.setTopBarTitle(tableScreenTitle) } val horizontalScrollState = rememberScrollState() - val dateColumnWidth = 130.dp - val minDataCellWidth = 110.dp // Slightly wider to accommodate value + difference + val dateColMin = 100.dp + val dateColMax = 160.dp + val colWidth = 110.dp + val commentWidth = 250.dp Column(modifier = Modifier.fillMaxSize()) { // --- FILTER SELECTION ROW --- MeasurementTypeFilterRow( allMeasurementTypesProvider = { allAvailableTypesFromVM }, selectedTypeIdsFlowProvider = { sharedViewModel.userSettingRepository.selectedTableTypeIds }, - onPersistSelectedTypeIds = { idsToSave -> // idsToSave is Set - scope.launch { - sharedViewModel.userSettingRepository.saveSelectedTableTypeIds(idsToSave) - } + onPersistSelectedTypeIds = { idsToSave -> + scope.launch { sharedViewModel.userSettingRepository.saveSelectedTableTypeIds(idsToSave) } }, - // Logic to determine which types are available for selection in the filter row. - // Example: only show enabled types. - filterLogic = { allTypes -> - allTypes.filter { it.isEnabled } - }, - // Logic to determine which types are selected by default. + filterLogic = { allTypes -> allTypes.filter { it.isEnabled } }, defaultSelectionLogic = { availableFilteredTypes -> val defaultDesiredTypeIds = listOf( MeasurementTypeKey.WEIGHT.id, @@ -240,139 +257,148 @@ fun TableScreen( MeasurementTypeKey.MUSCLE.id, MeasurementTypeKey.COMMENT.id ) - availableFilteredTypes - .filter { type -> type.id in defaultDesiredTypeIds && type.isEnabled } + .filter { it.id in defaultDesiredTypeIds && it.isEnabled } .map { it.id } }, onSelectionChanged = { newSelectedIds -> selectedColumnIdsFromFilter.clear() selectedColumnIdsFromFilter.addAll(newSelectedIds) }, - allowEmptySelection = false // Or true, depending on desired behavior + allowEmptySelection = false ) HorizontalDivider() // --- TABLE CONTENT --- - if (isLoading) { - Box( - Modifier - .fillMaxSize() - .padding(16.dp), Alignment.Center - ) { - CircularProgressIndicator() + when { + isLoading -> { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { CircularProgressIndicator() } } - } else if (enrichedMeasurements.isEmpty() && displayedTypes.isEmpty()) { - Box( - Modifier - .fillMaxSize() - .padding(16.dp), Alignment.Center - ) { Text(noColumnsOrMeasurementsMessage) } - } else if (enrichedMeasurements.isEmpty()) { - Box( - Modifier - .fillMaxSize() - .padding(16.dp), Alignment.Center - ) { Text(noMeasurementsMessage) } - } else if (displayedTypes.isEmpty()) { - Box( - Modifier - .fillMaxSize() - .padding(16.dp), Alignment.Center - ) { Text(noColumnsSelectedMessage) } - } else if (tableData.isEmpty()) { - // This case implies data exists, but not for the currently selected combination of columns. - Box( - Modifier - .fillMaxSize() - .padding(16.dp), Alignment.Center - ) { Text(noDataForSelectionMessage) } - } else { - // --- HEADER ROW --- - Row( - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(vertical = 8.dp) // Vertical padding for the header row - .height(IntrinsicSize.Min), // Ensures cells in row have same height, accommodating multi-line text - verticalAlignment = Alignment.CenterVertically - ) { - TableHeaderCellInternal( - text = dateColumnHeader, - modifier = Modifier - .width(dateColumnWidth) - .padding(horizontal = 6.dp) // Padding within the header cell - .fillMaxHeight(), - alignment = TextAlign.Start - ) - // Scrollable header cells for measurement types + + enrichedMeasurements.isEmpty() && displayedTypes.isEmpty() -> { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noColumnsOrMeasurementsMessage) } + } + + enrichedMeasurements.isEmpty() -> { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noMeasurementsMessage) } + } + + displayedTypes.isEmpty() -> { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noColumnsSelectedMessage) } + } + + tableData.isEmpty() -> { + Box( + Modifier + .fillMaxSize() + .padding(16.dp), Alignment.Center + ) { Text(noDataForSelectionMessage) } + } + + else -> { + // --- HEADER ROW --- Row( Modifier - .weight(1f) - .horizontalScroll(horizontalScrollState) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 8.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically ) { - displayedTypes.forEach { type -> - TableHeaderCellInternal( - text = type.getDisplayName(LocalContext.current), // Measurement type name as header - modifier = Modifier - .width(minDataCellWidth) - .padding(horizontal = 6.dp) // Padding within the header cell - .fillMaxHeight(), - alignment = TextAlign.End // Align numeric headers to the end - ) - } - } - } - HorizontalDivider() - - // --- DATA ROWS --- - LazyColumn(Modifier.fillMaxSize()) { - items(tableData, key = { it.measurementId }) { rowData -> + TableHeaderCellInternal( + text = dateColumnHeader, + modifier = Modifier + .widthIn(min = dateColMin, max = dateColMax) + .padding(horizontal = 6.dp) + .fillMaxHeight(), + alignment = TextAlign.Start + ) Row( Modifier - .fillMaxWidth() - .clickable { - navController.navigate( - Routes.measurementDetail( - rowData.measurementId, - sharedViewModel.selectedUserId.value // Pass current user ID if needed by detail screen - ) - ) - } - .height(IntrinsicSize.Min), // Important for variable cell height based on content - verticalAlignment = Alignment.CenterVertically + .weight(1f) + .horizontalScroll(horizontalScrollState) ) { - // Fixed date cell - TableDataCellInternal( - cellData = null, // No TableCellData for the date itself - fixedText = rowData.formattedTimestamp, - modifier = Modifier - .width(dateColumnWidth) - .background(MaterialTheme.colorScheme.surface) // Ensure consistent background - .fillMaxHeight(), - alignment = TextAlign.Start, - isDateCell = true - ) - // Scrollable data cells - Row( - Modifier - .weight(1f) - .horizontalScroll(horizontalScrollState) - .fillMaxHeight() - ) { - displayedTypes.forEach { colType -> - val cellData = rowData.values[colType.id] - TableDataCellInternal( - cellData = cellData, - modifier = Modifier - .width(minDataCellWidth) - .fillMaxHeight(), // Ensures cells in row have same height - alignment = TextAlign.End // Numeric data usually aligned to end - ) - } + displayedTypes.forEach { type -> + val width = if (type.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth + TableHeaderCellInternal( + text = type.getDisplayName(LocalContext.current), + modifier = Modifier + .width(width) + .padding(horizontal = 6.dp) + .fillMaxHeight(), + alignment = TextAlign.Center + ) } } - HorizontalDivider() + } + HorizontalDivider() + + // --- DATA ROWS --- + LazyColumn(Modifier.fillMaxSize()) { + items(tableData, key = { it.measurementId }) { rowData -> + Row( + Modifier + .fillMaxWidth() + .clickable { + navController.navigate( + Routes.measurementDetail( + rowData.measurementId, + sharedViewModel.selectedUserId.value + ) + ) + } + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + // Date cell (fixed column) + TableDataCellInternal( + cellData = null, + fixedText = rowData.formattedTimestamp, + modifier = Modifier + .widthIn(min = dateColMin, max = dateColMax) + .background(MaterialTheme.colorScheme.surface) + .fillMaxHeight(), + alignment = TextAlign.Start, + isDateCell = true + ) + // Scrollable value cells + Row( + Modifier + .weight(1f) + .horizontalScroll(horizontalScrollState) + .fillMaxHeight() + ) { + displayedTypes.forEach { colType -> + val cellData = rowData.values[colType.id] + val width = if (colType.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth + TableDataCellInternal( + cellData = cellData, + modifier = Modifier + .width(width) + .fillMaxHeight(), + alignment = if (colType.key == MeasurementTypeKey.COMMENT) TextAlign.Start else TextAlign.End + ) + } + } + } + HorizontalDivider() + } } } } @@ -380,11 +406,7 @@ fun TableScreen( } /** - * A composable function for rendering a header cell in the table. - * - * @param text The text to display in the header cell. - * @param modifier The modifier to be applied to the Text composable. - * @param alignment The text alignment within the cell. + * Header cell. */ @Composable fun TableHeaderCellInternal( @@ -397,25 +419,16 @@ fun TableHeaderCellInternal( style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, textAlign = alignment, - maxLines = 2, // Allow up to two lines for longer headers + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = modifier - .padding(vertical = 4.dp) // Vertical padding for text within the header cell - .fillMaxHeight() // Ensures the cell takes up the full height of the header row + .padding(vertical = 4.dp) + .fillMaxHeight() ) } /** - * A composable function for rendering a data cell in the table. - * - * This cell can display either a fixed text (for date cells) or formatted measurement data - * including value, unit, and trend indicator. - * - * @param cellData The [TableCellData] to display. Null for date cells if `fixedText` is provided. - * @param modifier The modifier to be applied to the cell's Box container. - * @param alignment The text alignment for the primary content of the cell. - * @param fixedText A fixed string to display, used primarily for the date cell. - * @param isDateCell A boolean indicating if this cell is the fixed date cell. + * Data cell (handles date or value; adds eval symbol near the value). */ @Composable fun TableDataCellInternal( @@ -425,84 +438,125 @@ fun TableDataCellInternal( fixedText: String? = null, isDateCell: Boolean = false ) { + val symbolColWidth = 18.dp // stable space for symbol + Box( - modifier = modifier.padding(horizontal = 8.dp, vertical = 6.dp), // Padding inside each cell - // Date cells are aligned to CenterStart, value cells to TopEnd for better layout with potential difference text + modifier = modifier.padding(horizontal = 8.dp, vertical = 6.dp), contentAlignment = if (isDateCell) Alignment.CenterStart else Alignment.TopEnd ) { if (isDateCell && fixedText != null) { - // Display for the fixed date cell Text( text = fixedText, style = MaterialTheme.typography.bodyMedium, textAlign = alignment, - maxLines = 2, // Allow date to wrap if necessary + maxLines = 2, overflow = TextOverflow.Ellipsis ) } else if (cellData != null) { - // Display for measurement data cells - Column(horizontalAlignment = Alignment.End) { // Align content to the end (right) - Text( - text = "${cellData.displayValue}${cellData.unit}", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Display difference and trend if available + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End + ) { + // --- Line 1: Value + Symbol in one Row --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + val unitPart = if (cellData.unit.isNotEmpty()) " ${cellData.unit}" else "" + Text( + text = "${cellData.displayValue}$unitPart", + style = MaterialTheme.typography.bodyLarge, + textAlign = alignment, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .alignByBaseline() + ) + + if (cellData.evalState != null) { + val symbol = when { + cellData.flagged -> "!" + cellData.evalState == EvaluationState.HIGH -> "▲" + cellData.evalState == EvaluationState.LOW -> "▼" + else -> "●" + } + val color = if (cellData.flagged) { + MaterialTheme.colorScheme.error + } else { + cellData.evalState.toColor() + } + Box( + modifier = Modifier + .width(symbolColWidth) + .alignByBaseline(), // keep baseline alignment with value + contentAlignment = Alignment.CenterEnd + ) { + Text( + text = symbol, + color = color, + style = MaterialTheme.typography.bodyLarge + ) + } + } else { + Spacer(modifier = Modifier.width(symbolColWidth)) + } + } + + // --- Line 2: Diff stays under the value --- if (cellData.difference != null && cellData.trend != Trend.NOT_APPLICABLE) { - Spacer(modifier = Modifier.height(1.dp)) // Small space between value and difference + Spacer(modifier = Modifier.height(1.dp)) Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { - val trendIconVector = when (cellData.trend) { - Trend.UP -> Icons.Filled.ArrowUpward - Trend.DOWN -> Icons.Filled.ArrowDownward - else -> null // No icon for Trend.NONE or Trend.NOT_APPLICABLE - } - // Use a subtle color for the difference text and icon - val diffColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - val trendContentDescription = when (cellData.trend) { - Trend.UP -> stringResource(R.string.table_trend_up) - Trend.DOWN -> stringResource(R.string.table_trend_down) - else -> null - } - - if (trendIconVector != null) { - Icon( - imageVector = trendIconVector, - contentDescription = trendContentDescription, - tint = diffColor, - modifier = Modifier.size(12.dp) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + val trendIconVector = when (cellData.trend) { + Trend.UP -> Icons.Filled.ArrowUpward + Trend.DOWN -> Icons.Filled.ArrowDownward + else -> null + } + val diffColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + if (trendIconVector != null) { + Icon( + imageVector = trendIconVector, + contentDescription = null, + tint = diffColor, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + } + val diffText = + (if (cellData.difference > 0 && cellData.trend != Trend.NONE) "+" else "") + + when (cellData.originalInputType) { + InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), cellData.difference) + InputFieldType.INT -> cellData.difference.toInt().toString() + else -> "" + } + + (if (cellData.unit.isNotEmpty()) " ${cellData.unit}" else "") + Text( + text = diffText, + style = MaterialTheme.typography.bodySmall, + color = diffColor, + textAlign = TextAlign.End ) - Spacer(modifier = Modifier.width(2.dp)) } - Text( - text = (if (cellData.difference > 0 && cellData.trend != Trend.NONE) "+" else "") + // Add "+" for positive changes - when (cellData.originalInputType) { // Format difference based on original type - InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), cellData.difference) - InputFieldType.INT -> cellData.difference.toInt().toString() - else -> "" // Should not happen for types with difference - } + " ${cellData.unit}", // Append unit to the difference - style = MaterialTheme.typography.bodySmall, - color = diffColor, - textAlign = TextAlign.End - ) + Spacer(modifier = Modifier.width(symbolColWidth)) } - } else if (cellData.originalInputType == InputFieldType.FLOAT || cellData.originalInputType == InputFieldType.INT) { - // Add a spacer if there's no difference to maintain consistent cell height for numeric types - // The height should roughly match the space taken by the difference text and icon. - Spacer(modifier = Modifier.height((MaterialTheme.typography.bodySmall.fontSize.value + 4).dp)) // Adjust dp as needed } } } else { - // Fallback for empty cells (should ideally not happen if data is processed correctly) Text( - text = "-", // Placeholder for empty data + text = "-", style = MaterialTheme.typography.bodyLarge, textAlign = alignment, - modifier = Modifier.fillMaxHeight() // Maintain cell height + modifier = Modifier.fillMaxHeight() ) } }