mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-21 07:51:46 +02:00
Displays evaluation state symbols (▲, ▼, ●, !) next to values in table screen, similar to the overview screen.
This commit is contained in:
@@ -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<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 =
|
||||
|
@@ -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<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,
|
||||
|
@@ -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,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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user