1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-21 16:02:04 +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
}
}
/**
* 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 ---
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.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. 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.
*
@@ -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,

View File

@@ -15,8 +15,9 @@
* You should have received a copy of the GNU General Public License
* 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.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<Int>() }
// 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<Int, TableCellData?>
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<String>
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,85 +257,92 @@ 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) {
when {
isLoading -> {
Box(
Modifier
.fillMaxSize()
.padding(16.dp), Alignment.Center
) {
CircularProgressIndicator()
) { CircularProgressIndicator() }
}
} else if (enrichedMeasurements.isEmpty() && displayedTypes.isEmpty()) {
enrichedMeasurements.isEmpty() && displayedTypes.isEmpty() -> {
Box(
Modifier
.fillMaxSize()
.padding(16.dp), Alignment.Center
) { Text(noColumnsOrMeasurementsMessage) }
} else if (enrichedMeasurements.isEmpty()) {
}
enrichedMeasurements.isEmpty() -> {
Box(
Modifier
.fillMaxSize()
.padding(16.dp), Alignment.Center
) { Text(noMeasurementsMessage) }
} else if (displayedTypes.isEmpty()) {
}
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.
}
tableData.isEmpty() -> {
Box(
Modifier
.fillMaxSize()
.padding(16.dp), Alignment.Center
) { Text(noDataForSelectionMessage) }
} else {
}
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
.padding(vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
TableHeaderCellInternal(
text = dateColumnHeader,
modifier = Modifier
.width(dateColumnWidth)
.padding(horizontal = 6.dp) // Padding within the header cell
.widthIn(min = dateColMin, max = dateColMax)
.padding(horizontal = 6.dp)
.fillMaxHeight(),
alignment = TextAlign.Start
)
// Scrollable header cells for measurement types
Row(
Modifier
.weight(1f)
.horizontalScroll(horizontalScrollState)
) {
displayedTypes.forEach { type ->
val width = if (type.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth
TableHeaderCellInternal(
text = type.getDisplayName(LocalContext.current), // Measurement type name as header
text = type.getDisplayName(LocalContext.current),
modifier = Modifier
.width(minDataCellWidth)
.padding(horizontal = 6.dp) // Padding within the header cell
.width(width)
.padding(horizontal = 6.dp)
.fillMaxHeight(),
alignment = TextAlign.End // Align numeric headers to the end
alignment = TextAlign.Center
)
}
}
@@ -335,25 +359,25 @@ fun TableScreen(
navController.navigate(
Routes.measurementDetail(
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
) {
// Fixed date cell
// Date cell (fixed column)
TableDataCellInternal(
cellData = null, // No TableCellData for the date itself
cellData = null,
fixedText = rowData.formattedTimestamp,
modifier = Modifier
.width(dateColumnWidth)
.background(MaterialTheme.colorScheme.surface) // Ensure consistent background
.widthIn(min = dateColMin, max = dateColMax)
.background(MaterialTheme.colorScheme.surface)
.fillMaxHeight(),
alignment = TextAlign.Start,
isDateCell = true
)
// Scrollable data cells
// Scrollable value cells
Row(
Modifier
.weight(1f)
@@ -362,12 +386,13 @@ fun TableScreen(
) {
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(minDataCellWidth)
.fillMaxHeight(), // Ensures cells in row have same height
alignment = TextAlign.End // Numeric data usually aligned to end
.width(width)
.fillMaxHeight(),
alignment = if (colType.key == MeasurementTypeKey.COMMENT) TextAlign.Start else TextAlign.End
)
}
}
@@ -377,14 +402,11 @@ 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
if (cellData.difference != null && cellData.trend != Trend.NOT_APPLICABLE) {
Spacer(modifier = Modifier.height(1.dp)) // Small space between value and difference
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))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
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 // 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
}
val diffColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
if (trendIconVector != null) {
Icon(
imageVector = trendIconVector,
contentDescription = trendContentDescription,
contentDescription = null,
tint = diffColor,
modifier = Modifier.size(12.dp)
)
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
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 -> "" // Should not happen for types with difference
} + " ${cellData.unit}", // Append unit to the difference
else -> ""
} +
(if (cellData.unit.isNotEmpty()) " ${cellData.unit}" else "")
Text(
text = diffText,
style = MaterialTheme.typography.bodySmall,
color = diffColor,
textAlign = TextAlign.End
)
}
} 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
Spacer(modifier = Modifier.width(symbolColWidth))
}
}
}
} 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()
)
}
}