1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-22 08:13:43 +02:00

Displays evaluation state symbols (▲, ▼, ●, !) next to values in table screen, similar to the overview screen.

This commit is contained in:
oliexdev
2025-08-18 18:12:27 +02:00
parent cf6d834db7
commit 3a13162f9e
3 changed files with 327 additions and 275 deletions

View File

@@ -81,6 +81,26 @@ object MeasurementEvaluator {
else -> null else -> null
} }
} }
/**
* Returns a broad **plausible** percentage range for selected measurement types.
*
* This is **not** a clinical reference band. Its 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<Float>? =
when (typeKey) {
MeasurementTypeKey.WATER -> 35f..75f
MeasurementTypeKey.BODY_FAT -> 3f..70f
MeasurementTypeKey.MUSCLE -> 15f..60f
else -> null
}
// --- Body composition --- // --- Body composition ---
fun evalBodyFat(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = fun evalBodyFat(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult =

View File

@@ -91,7 +91,6 @@ import androidx.navigation.NavController
import com.health.openscale.R import com.health.openscale.R
import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.EvaluationState
import com.health.openscale.core.data.InputFieldType 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.data.Trend
import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.model.MeasurementWithValues
import com.health.openscale.core.database.UserPreferenceKeys 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 val noAgeBand: Boolean = evalResult?.let { it.lowLimit < 0f || it.highLimit < 0f } ?: false
// Flag 2: percent outside a plausible range (0..100) // Flag 2: percent outside a plausible range (0..100)
val plausible = plausiblePercentRangeFor(type.key) val plausible = MeasurementEvaluator.plausiblePercentRangeFor(type.key)
val outOfPlausibleRange = val outOfPlausibleRange =
if (numeric == null) { if (numeric == null) {
false 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. Its 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<Float>? =
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. * 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 // 2) Implausible value for percentage-based metrics
val unitName = type.unit.displayName val unitName = type.unit.displayName
val plausible = plausiblePercentRangeFor(type.key) val plausible = MeasurementEvaluator.plausiblePercentRangeFor(type.key)
val outOfPlausibleRange = val outOfPlausibleRange =
if (numeric == null) { if (numeric == null) {
false false
@@ -1144,7 +1122,7 @@ fun MeasurementRowExpandable(
) )
} }
outOfPlausibleRange -> { outOfPlausibleRange -> {
val plausible = plausiblePercentRangeFor(type.key) ?: (0f..100f) val plausible = MeasurementEvaluator.plausiblePercentRangeFor(type.key) ?: (0f..100f)
EvaluationErrorBanner( EvaluationErrorBanner(
message = stringResource( message = stringResource(
R.string.eval_out_of_plausible_range_percent, R.string.eval_out_of_plausible_range_percent,

View File

@@ -15,8 +15,9 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -54,6 +56,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.InputFieldType
import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.Trend 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.navigation.Routes
import com.health.openscale.ui.screen.SharedViewModel import com.health.openscale.ui.screen.SharedViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -73,14 +78,7 @@ import java.util.Date
import java.util.Locale import java.util.Locale
/** /**
* Represents the data for a single cell in the table, excluding the date cell. * Data for a single (non-date) table 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 class TableCellData( data class TableCellData(
val typeId: Int, val typeId: Int,
@@ -88,17 +86,13 @@ data class TableCellData(
val unit: String, val unit: String,
val difference: Float? = null, val difference: Float? = null,
val trend: Trend = Trend.NOT_APPLICABLE, 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. * 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.
*/ */
data class TableRowDataInternal( data class TableRowDataInternal(
val measurementId: Int, val measurementId: Int,
@@ -108,15 +102,7 @@ data class TableRowDataInternal(
) )
/** /**
* Composable screen that displays measurement data in a tabular format. * Table of measurements with a fixed date column and horizontally scrollable value columns.
*
* 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.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -128,72 +114,109 @@ fun TableScreen(
val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState() val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState()
val isLoading by sharedViewModel.isBaseDataLoading.collectAsState() val isLoading by sharedViewModel.isBaseDataLoading.collectAsState()
val allAvailableTypesFromVM by sharedViewModel.measurementTypes.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<Int>() } val selectedColumnIdsFromFilter = remember { mutableStateListOf<Int>() }
// Determines the actual measurement types to display as columns based on user selection.
val displayedTypes = val displayedTypes =
remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) { remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) {
allAvailableTypesFromVM.filter { type -> allAvailableTypesFromVM.filter { it.id in selectedColumnIdsFromFilter }
type.id in selectedColumnIdsFromFilter
}
} }
// Transforms enriched measurements into a list of TableRowDataInternal for easier rendering. // Transform enriched measurements -> table rows (compute eval state here).
val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM) { val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM, userEvaluationContext) {
if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) { if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) {
emptyList() emptyList()
} else { } else {
// Date formatter for the timestamp column. val dateFormatterDate = SimpleDateFormat.getDateInstance(
val dateFormatter = SimpleDateFormat("E, dd.MM.yy HH:mm", Locale.getDefault()) SimpleDateFormat.MEDIUM,
Locale.getDefault()
)
val dateFormatterTime = SimpleDateFormat.getTimeInstance(
SimpleDateFormat.SHORT,
Locale.getDefault()
)
enrichedMeasurements.map { enrichedItem -> // enrichedItem is EnrichedMeasurement enrichedMeasurements.map { enrichedItem ->
val cellValues = displayedTypes.associate { colType -> // Iterate over sorted, displayed types val ts = enrichedItem.measurementWithValues.measurement.timestamp
val cellValues = displayedTypes.associate { colType ->
val typeId = colType.id val typeId = colType.id
// Find the corresponding value with trend from the enrichedItem
val valueWithTrend = enrichedItem.valuesWithTrend.find { it.currentValue.type.id == typeId } val valueWithTrend = enrichedItem.valuesWithTrend.find { it.currentValue.type.id == typeId }
if (valueWithTrend != null) { if (valueWithTrend != null) {
val originalMeasurementValue = valueWithTrend.currentValue.value // This is MeasurementValue val originalMeasurementValue = valueWithTrend.currentValue.value
val actualType = valueWithTrend.currentValue.type // This is MeasurementType val actualType = valueWithTrend.currentValue.type
val displayValueStr = when (actualType.inputType) { val displayValueStr = when (actualType.inputType) {
InputFieldType.FLOAT -> originalMeasurementValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) } ?: "-" InputFieldType.FLOAT -> originalMeasurementValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) } ?: "-"
InputFieldType.INT -> originalMeasurementValue.intValue?.toString() ?: "-" InputFieldType.INT -> originalMeasurementValue.intValue?.toString() ?: "-"
InputFieldType.TEXT -> originalMeasurementValue.textValue ?: "-" InputFieldType.TEXT -> originalMeasurementValue.textValue ?: "-"
// Add other InputFieldTypes here if needed (DATE, TIME etc.) else -> originalMeasurementValue.textValue
else -> originalMeasurementValue.textValue ?: originalMeasurementValue.floatValue?.toString() ?: originalMeasurementValue.intValue?.toString() ?: "-" ?: originalMeasurementValue.floatValue?.toString()
?: originalMeasurementValue.intValue?.toString()
?: "-"
} }
val unitStr = if (displayValueStr != "-") actualType.unit.displayName else "" 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 to TableCellData(
typeId = typeId, typeId = typeId,
displayValue = displayValueStr, displayValue = displayValueStr,
unit = unitStr, unit = unitStr,
difference = valueWithTrend.difference, // Use directly difference = valueWithTrend.difference,
trend = valueWithTrend.trend, // Use directly trend = valueWithTrend.trend,
originalInputType = actualType.inputType originalInputType = actualType.inputType,
evalState = evalResult?.state,
flagged = noAgeBand || outOfPlausibleRange
) )
} else { } 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 to TableCellData(
typeId = typeId, typeId = typeId,
displayValue = "-", displayValue = "-",
unit = colType.unit.displayName, // Show unit even if no value, for consistency unit = colType.unit.displayName,
difference = null, difference = null,
trend = Trend.NOT_APPLICABLE, trend = Trend.NOT_APPLICABLE,
originalInputType = colType.inputType originalInputType = colType.inputType,
evalState = null,
flagged = false
) )
} }
} }
TableRowDataInternal( TableRowDataInternal(
measurementId = enrichedItem.measurementWithValues.measurement.id, measurementId = enrichedItem.measurementWithValues.measurement.id,
timestamp = enrichedItem.measurementWithValues.measurement.timestamp, timestamp = ts,
formattedTimestamp = dateFormatter.format(Date(enrichedItem.measurementWithValues.measurement.timestamp)), formattedTimestamp = dateFormatterDate.format(Date(ts)) + "\n" + dateFormatterTime.format(Date(ts)),
values = cellValues // cellValues is already Map<Int, TableCellData?> values = cellValues
) )
} }
} }
@@ -206,31 +229,25 @@ fun TableScreen(
val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection) val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection)
val dateColumnHeader = stringResource(id = R.string.table_header_date) val dateColumnHeader = stringResource(id = R.string.table_header_date)
LaunchedEffect(Unit, tableScreenTitle) { LaunchedEffect(Unit, tableScreenTitle) {
sharedViewModel.setTopBarTitle(tableScreenTitle) sharedViewModel.setTopBarTitle(tableScreenTitle)
} }
val horizontalScrollState = rememberScrollState() val horizontalScrollState = rememberScrollState()
val dateColumnWidth = 130.dp val dateColMin = 100.dp
val minDataCellWidth = 110.dp // Slightly wider to accommodate value + difference val dateColMax = 160.dp
val colWidth = 110.dp
val commentWidth = 250.dp
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// --- FILTER SELECTION ROW --- // --- FILTER SELECTION ROW ---
MeasurementTypeFilterRow( MeasurementTypeFilterRow(
allMeasurementTypesProvider = { allAvailableTypesFromVM }, allMeasurementTypesProvider = { allAvailableTypesFromVM },
selectedTypeIdsFlowProvider = { sharedViewModel.userSettingRepository.selectedTableTypeIds }, selectedTypeIdsFlowProvider = { sharedViewModel.userSettingRepository.selectedTableTypeIds },
onPersistSelectedTypeIds = { idsToSave -> // idsToSave is Set<String> onPersistSelectedTypeIds = { idsToSave ->
scope.launch { scope.launch { sharedViewModel.userSettingRepository.saveSelectedTableTypeIds(idsToSave) }
sharedViewModel.userSettingRepository.saveSelectedTableTypeIds(idsToSave)
}
}, },
// Logic to determine which types are available for selection in the filter row. filterLogic = { allTypes -> allTypes.filter { it.isEnabled } },
// Example: only show enabled types.
filterLogic = { allTypes ->
allTypes.filter { it.isEnabled }
},
// Logic to determine which types are selected by default.
defaultSelectionLogic = { availableFilteredTypes -> defaultSelectionLogic = { availableFilteredTypes ->
val defaultDesiredTypeIds = listOf( val defaultDesiredTypeIds = listOf(
MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.WEIGHT.id,
@@ -240,85 +257,92 @@ fun TableScreen(
MeasurementTypeKey.MUSCLE.id, MeasurementTypeKey.MUSCLE.id,
MeasurementTypeKey.COMMENT.id MeasurementTypeKey.COMMENT.id
) )
availableFilteredTypes availableFilteredTypes
.filter { type -> type.id in defaultDesiredTypeIds && type.isEnabled } .filter { it.id in defaultDesiredTypeIds && it.isEnabled }
.map { it.id } .map { it.id }
}, },
onSelectionChanged = { newSelectedIds -> onSelectionChanged = { newSelectedIds ->
selectedColumnIdsFromFilter.clear() selectedColumnIdsFromFilter.clear()
selectedColumnIdsFromFilter.addAll(newSelectedIds) selectedColumnIdsFromFilter.addAll(newSelectedIds)
}, },
allowEmptySelection = false // Or true, depending on desired behavior allowEmptySelection = false
) )
HorizontalDivider() HorizontalDivider()
// --- TABLE CONTENT --- // --- TABLE CONTENT ---
if (isLoading) { when {
isLoading -> {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), Alignment.Center .padding(16.dp), Alignment.Center
) { ) { CircularProgressIndicator() }
CircularProgressIndicator()
} }
} else if (enrichedMeasurements.isEmpty() && displayedTypes.isEmpty()) {
enrichedMeasurements.isEmpty() && displayedTypes.isEmpty() -> {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), Alignment.Center .padding(16.dp), Alignment.Center
) { Text(noColumnsOrMeasurementsMessage) } ) { Text(noColumnsOrMeasurementsMessage) }
} else if (enrichedMeasurements.isEmpty()) { }
enrichedMeasurements.isEmpty() -> {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), Alignment.Center .padding(16.dp), Alignment.Center
) { Text(noMeasurementsMessage) } ) { Text(noMeasurementsMessage) }
} else if (displayedTypes.isEmpty()) { }
displayedTypes.isEmpty() -> {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), Alignment.Center .padding(16.dp), Alignment.Center
) { Text(noColumnsSelectedMessage) } ) { Text(noColumnsSelectedMessage) }
} else if (tableData.isEmpty()) { }
// This case implies data exists, but not for the currently selected combination of columns.
tableData.isEmpty() -> {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), Alignment.Center .padding(16.dp), Alignment.Center
) { Text(noDataForSelectionMessage) } ) { Text(noDataForSelectionMessage) }
} else { }
else -> {
// --- HEADER ROW --- // --- HEADER ROW ---
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding(vertical = 8.dp) // Vertical padding for the header row .padding(vertical = 8.dp)
.height(IntrinsicSize.Min), // Ensures cells in row have same height, accommodating multi-line text .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TableHeaderCellInternal( TableHeaderCellInternal(
text = dateColumnHeader, text = dateColumnHeader,
modifier = Modifier modifier = Modifier
.width(dateColumnWidth) .widthIn(min = dateColMin, max = dateColMax)
.padding(horizontal = 6.dp) // Padding within the header cell .padding(horizontal = 6.dp)
.fillMaxHeight(), .fillMaxHeight(),
alignment = TextAlign.Start alignment = TextAlign.Start
) )
// Scrollable header cells for measurement types
Row( Row(
Modifier Modifier
.weight(1f) .weight(1f)
.horizontalScroll(horizontalScrollState) .horizontalScroll(horizontalScrollState)
) { ) {
displayedTypes.forEach { type -> displayedTypes.forEach { type ->
val width = if (type.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth
TableHeaderCellInternal( TableHeaderCellInternal(
text = type.getDisplayName(LocalContext.current), // Measurement type name as header text = type.getDisplayName(LocalContext.current),
modifier = Modifier modifier = Modifier
.width(minDataCellWidth) .width(width)
.padding(horizontal = 6.dp) // Padding within the header cell .padding(horizontal = 6.dp)
.fillMaxHeight(), .fillMaxHeight(),
alignment = TextAlign.End // Align numeric headers to the end alignment = TextAlign.Center
) )
} }
} }
@@ -335,25 +359,25 @@ fun TableScreen(
navController.navigate( navController.navigate(
Routes.measurementDetail( Routes.measurementDetail(
rowData.measurementId, rowData.measurementId,
sharedViewModel.selectedUserId.value // Pass current user ID if needed by detail screen sharedViewModel.selectedUserId.value
) )
) )
} }
.height(IntrinsicSize.Min), // Important for variable cell height based on content .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Fixed date cell // Date cell (fixed column)
TableDataCellInternal( TableDataCellInternal(
cellData = null, // No TableCellData for the date itself cellData = null,
fixedText = rowData.formattedTimestamp, fixedText = rowData.formattedTimestamp,
modifier = Modifier modifier = Modifier
.width(dateColumnWidth) .widthIn(min = dateColMin, max = dateColMax)
.background(MaterialTheme.colorScheme.surface) // Ensure consistent background .background(MaterialTheme.colorScheme.surface)
.fillMaxHeight(), .fillMaxHeight(),
alignment = TextAlign.Start, alignment = TextAlign.Start,
isDateCell = true isDateCell = true
) )
// Scrollable data cells // Scrollable value cells
Row( Row(
Modifier Modifier
.weight(1f) .weight(1f)
@@ -362,12 +386,13 @@ fun TableScreen(
) { ) {
displayedTypes.forEach { colType -> displayedTypes.forEach { colType ->
val cellData = rowData.values[colType.id] val cellData = rowData.values[colType.id]
val width = if (colType.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth
TableDataCellInternal( TableDataCellInternal(
cellData = cellData, cellData = cellData,
modifier = Modifier modifier = Modifier
.width(minDataCellWidth) .width(width)
.fillMaxHeight(), // Ensures cells in row have same height .fillMaxHeight(),
alignment = TextAlign.End // Numeric data usually aligned to end alignment = if (colType.key == MeasurementTypeKey.COMMENT) TextAlign.Start else TextAlign.End
) )
} }
} }
@@ -378,13 +403,10 @@ fun TableScreen(
} }
} }
} }
}
/** /**
* A composable function for rendering a header cell in the table. * Header cell.
*
* @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.
*/ */
@Composable @Composable
fun TableHeaderCellInternal( fun TableHeaderCellInternal(
@@ -397,25 +419,16 @@ fun TableHeaderCellInternal(
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = alignment, textAlign = alignment,
maxLines = 2, // Allow up to two lines for longer headers maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
.padding(vertical = 4.dp) // Vertical padding for text within the header cell .padding(vertical = 4.dp)
.fillMaxHeight() // Ensures the cell takes up the full height of the header row .fillMaxHeight()
) )
} }
/** /**
* A composable function for rendering a data cell in the table. * Data cell (handles date or value; adds eval symbol near the value).
*
* 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.
*/ */
@Composable @Composable
fun TableDataCellInternal( fun TableDataCellInternal(
@@ -425,84 +438,125 @@ fun TableDataCellInternal(
fixedText: String? = null, fixedText: String? = null,
isDateCell: Boolean = false isDateCell: Boolean = false
) { ) {
val symbolColWidth = 18.dp // stable space for symbol
Box( Box(
modifier = modifier.padding(horizontal = 8.dp, vertical = 6.dp), // Padding inside each cell modifier = modifier.padding(horizontal = 8.dp, vertical = 6.dp),
// Date cells are aligned to CenterStart, value cells to TopEnd for better layout with potential difference text
contentAlignment = if (isDateCell) Alignment.CenterStart else Alignment.TopEnd contentAlignment = if (isDateCell) Alignment.CenterStart else Alignment.TopEnd
) { ) {
if (isDateCell && fixedText != null) { if (isDateCell && fixedText != null) {
// Display for the fixed date cell
Text( Text(
text = fixedText, text = fixedText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = alignment, textAlign = alignment,
maxLines = 2, // Allow date to wrap if necessary maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} else if (cellData != null) { } else if (cellData != null) {
// Display for measurement data cells Column(
Column(horizontalAlignment = Alignment.End) { // Align content to the end (right) modifier = Modifier.fillMaxWidth(),
Text( horizontalAlignment = Alignment.End
text = "${cellData.displayValue}${cellData.unit}", ) {
style = MaterialTheme.typography.bodyLarge, // --- Line 1: Value + Symbol in one Row ---
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Display difference and trend if available
if (cellData.difference != null && cellData.trend != Trend.NOT_APPLICABLE) {
Spacer(modifier = Modifier.height(1.dp)) // Small space between value and difference
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))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
val trendIconVector = when (cellData.trend) { val trendIconVector = when (cellData.trend) {
Trend.UP -> Icons.Filled.ArrowUpward Trend.UP -> Icons.Filled.ArrowUpward
Trend.DOWN -> Icons.Filled.ArrowDownward 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 else -> null
} }
val diffColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
if (trendIconVector != null) { if (trendIconVector != null) {
Icon( Icon(
imageVector = trendIconVector, imageVector = trendIconVector,
contentDescription = trendContentDescription, contentDescription = null,
tint = diffColor, tint = diffColor,
modifier = Modifier.size(12.dp) modifier = Modifier.size(12.dp)
) )
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
} }
Text( val diffText =
text = (if (cellData.difference > 0 && cellData.trend != Trend.NONE) "+" else "") + // Add "+" for positive changes (if (cellData.difference > 0 && cellData.trend != Trend.NONE) "+" else "") +
when (cellData.originalInputType) { // Format difference based on original type when (cellData.originalInputType) {
InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), cellData.difference) InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), cellData.difference)
InputFieldType.INT -> cellData.difference.toInt().toString() InputFieldType.INT -> cellData.difference.toInt().toString()
else -> "" // Should not happen for types with difference else -> ""
} + " ${cellData.unit}", // Append unit to the difference } +
(if (cellData.unit.isNotEmpty()) " ${cellData.unit}" else "")
Text(
text = diffText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = diffColor, color = diffColor,
textAlign = TextAlign.End textAlign = TextAlign.End
) )
} }
} else if (cellData.originalInputType == InputFieldType.FLOAT || cellData.originalInputType == InputFieldType.INT) { Spacer(modifier = Modifier.width(symbolColWidth))
// 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 { } else {
// Fallback for empty cells (should ideally not happen if data is processed correctly)
Text( Text(
text = "-", // Placeholder for empty data text = "-",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = alignment, textAlign = alignment,
modifier = Modifier.fillMaxHeight() // Maintain cell height modifier = Modifier.fillMaxHeight()
) )
} }
} }