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:
@@ -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. 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 ---
|
// --- Body composition ---
|
||||||
|
|
||||||
fun evalBodyFat(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult =
|
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.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. 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.
|
* 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,
|
||||||
|
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user