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

enhances unit conversion capabilities, particularly for BODY_FAT, WATER, and MUSCLE measurement types. These types can now be converted between percentage (%) and absolute mass units (kg, lb, st)

This commit is contained in:
oliexdev
2025-08-16 10:36:18 +02:00
parent 5b03c4bd89
commit d61670cc84
6 changed files with 196 additions and 107 deletions

View File

@@ -153,9 +153,9 @@ enum class MeasurementTypeKey(
) { ) {
WEIGHT(1, R.string.measurement_type_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), WEIGHT(1, R.string.measurement_type_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)),
BMI(2, R.string.measurement_type_bmi, listOf(UnitType.NONE), listOf(InputFieldType.FLOAT)), BMI(2, R.string.measurement_type_bmi, listOf(UnitType.NONE), listOf(InputFieldType.FLOAT)),
BODY_FAT(3, R.string.measurement_type_body_fat, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), BODY_FAT(3, R.string.measurement_type_body_fat, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)),
WATER(4, R.string.measurement_type_water, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), WATER(4, R.string.measurement_type_water, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)),
MUSCLE(5, R.string.measurement_type_muscle, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), MUSCLE(5, R.string.measurement_type_muscle, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)),
LBM(6, R.string.measurement_type_lbm, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), LBM(6, R.string.measurement_type_lbm, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)),
BONE(7, R.string.measurement_type_bone, listOf(UnitType.KG, UnitType.LB), listOf(InputFieldType.FLOAT)), BONE(7, R.string.measurement_type_bone, listOf(UnitType.KG, UnitType.LB), listOf(InputFieldType.FLOAT)),
WAIST(8, R.string.measurement_type_waist, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), WAIST(8, R.string.measurement_type_waist, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)),
@@ -189,7 +189,11 @@ enum class UnitType(val displayName: String) {
CM("cm"), CM("cm"),
INCH("in"), INCH("in"),
KCAL("kcal"), KCAL("kcal"),
NONE("") NONE("");
fun isWeightUnit(): Boolean {
return this == KG || this == LB || this == ST
}
} }
enum class InputFieldType { enum class InputFieldType {

View File

@@ -272,7 +272,7 @@ class DatabaseRepository(
} }
} else { } else {
// If derived value is not null, insert or update it // If derived value is not null, insert or update it
val roundedValue = roundTo(derivedValue) // Apply rounding val roundedValue = CalculationUtil.roundTo(derivedValue) // Apply rounding
if (existingDerivedValueObject != null) { if (existingDerivedValueObject != null) {
if (existingDerivedValueObject.floatValue != roundedValue) { if (existingDerivedValueObject.floatValue != roundedValue) {
measurementValueDao.update(existingDerivedValueObject.copy(floatValue = roundedValue)) measurementValueDao.update(existingDerivedValueObject.copy(floatValue = roundedValue))
@@ -550,12 +550,5 @@ class DatabaseRepository(
fatPercentage fatPercentage
} }
} }
/**
* Rounds a float value to two decimal places.
*/
private fun roundTo(value: Float): Float {
return (value * 100).toInt() / 100.0f
}
} }

View File

@@ -39,6 +39,13 @@ object CalculationUtil {
return Period.between(birthDate, today).years return Period.between(birthDate, today).years
} }
/**
* Rounds a float value to two decimal places.
*/
fun roundTo(value: Float): Float {
return (value * 100).toInt() / 100.0f
}
} }
/** /**

View File

@@ -17,13 +17,16 @@
*/ */
package com.health.openscale.ui.screen.dialog package com.health.openscale.ui.screen.dialog
import androidx.compose.animation.core.copy
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -33,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -49,14 +53,17 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.health.openscale.R 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.UnitType
@Composable @Composable
fun NumberInputDialog( fun NumberInputDialog(
title: String, title: String,
initialValue: String, initialValue: String,
inputType: InputFieldType, inputType: InputFieldType,
unit: UnitType,
iconRes: Int, iconRes: Int,
color: Color, color: Color,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -112,6 +119,15 @@ fun NumberInputDialog(
} }
), ),
trailingIcon = { trailingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
text = unit.displayName,
modifier = Modifier.padding(end = 8.dp),
style = LocalTextStyle.current.copy(fontSize = 14.sp)
)
if (inputType == InputFieldType.INT || inputType == InputFieldType.FLOAT) { if (inputType == InputFieldType.INT || inputType == InputFieldType.FLOAT) {
Column { Column {
Icon( Icon(
@@ -134,6 +150,7 @@ fun NumberInputDialog(
) )
} }
} }
}
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )

View File

@@ -372,6 +372,7 @@ fun MeasurementDetailScreen(
title = dialogTitle, title = dialogTitle,
initialValue = initialDialogValue, initialValue = initialDialogValue,
inputType = currentType.inputType, inputType = currentType.inputType,
unit = currentType.unit,
iconRes = typeIconRes, iconRes = typeIconRes,
color = typeColor, color = typeColor,
onDismiss = { dialogTargetType = null }, onDismiss = { dialogTargetType = null },

View File

@@ -30,8 +30,11 @@ import com.health.openscale.core.data.Measurement
import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.MeasurementValue
import com.health.openscale.core.data.UnitType
import com.health.openscale.core.data.User import com.health.openscale.core.data.User
import com.health.openscale.core.data.WeightUnit
import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.model.MeasurementWithValues
import com.health.openscale.core.utils.CalculationUtil
import com.health.openscale.core.utils.Converters import com.health.openscale.core.utils.Converters
import com.health.openscale.core.utils.LogManager import com.health.openscale.core.utils.LogManager
import com.health.openscale.ui.screen.SharedViewModel import com.health.openscale.ui.screen.SharedViewModel
@@ -1158,28 +1161,31 @@ class SettingsViewModel(
*/ */
fun updateMeasurementTypeAndConvertDataViewModelCentric( fun updateMeasurementTypeAndConvertDataViewModelCentric(
originalType: MeasurementType, originalType: MeasurementType,
updatedType: MeasurementType, // This contains the new unit and other proposed changes from the UI updatedType: MeasurementType, // Contains the new unit and other proposed changes from the UI
showSnackbarMaster: Boolean = true showSnackbarMaster: Boolean = true
) { ) {
viewModelScope.launch { viewModelScope.launch {
val typeKey = originalType.key
val oldUnit = originalType.unit
val newUnit = updatedType.unit
LogManager.i( LogManager.i(
TAG, TAG,
"ViewModelCentric Update: Type ID ${originalType.id}. Original Unit: ${originalType.unit}, New Unit: ${updatedType.unit}" "ViewModelCentric Update for $typeKey (ID ${originalType.id}). From Unit: $oldUnit, To Unit: $newUnit"
) )
var conversionErrorOccurred = false var mainOperationErrorOccurred = false
var valuesConvertedCount = 0 var conversionProcessAttempted = false
var valuesSuccessfullyConvertedCount = 0
// 1. First, update the MeasurementType definition in the database. // 1. Update the MeasurementType definition in the database.
// This ensures that any subsequent recalculations of derived values
// will use the correct (new) unit for this type.
val finalTypeToUpdate = MeasurementType( val finalTypeToUpdate = MeasurementType(
id = originalType.id, id = originalType.id,
key = originalType.key, // Key should be immutable for an existing type key = typeKey,
name = updatedType.name, name = updatedType.name,
color = updatedType.color, color = updatedType.color,
icon = updatedType.icon, icon = updatedType.icon,
unit = updatedType.unit, // Crucial: The new unit unit = newUnit, // Apply the new unit
inputType = updatedType.inputType, inputType = updatedType.inputType,
displayOrder = originalType.displayOrder, displayOrder = originalType.displayOrder,
isDerived = originalType.isDerived, isDerived = originalType.isDerived,
@@ -1192,101 +1198,162 @@ class SettingsViewModel(
repository.updateMeasurementType(finalTypeToUpdate) repository.updateMeasurementType(finalTypeToUpdate)
LogManager.i( LogManager.i(
TAG, TAG,
"MeasurementType (ID: ${originalType.id}) definition updated successfully to new unit '${finalTypeToUpdate.unit}'." "MeasurementType $typeKey (ID: ${originalType.id}) definition updated successfully to new unit '$newUnit'."
) )
} catch (e: Exception) { } catch (e: Exception) {
LogManager.e(TAG, "Error updating MeasurementType (ID: ${originalType.id}) definition itself", e) LogManager.e(TAG, "Error updating MeasurementType $typeKey (ID: ${originalType.id}) definition itself", e)
sharedViewModel.showSnackbar( sharedViewModel.showSnackbar(
messageResId = R.string.measurement_type_updated_error, messageResId = R.string.measurement_type_updated_error,
// Consider using context.getString for display names if not available in originalType formatArgs = listOf(originalType.name ?: typeKey.toString())
formatArgs = listOf(originalType.name ?: originalType.key.toString())
) )
conversionErrorOccurred = true // Prevent further steps if this critical update fails mainOperationErrorOccurred = true
} }
// 2. If the type definition was updated successfully AND the unit has changed for a FLOAT type, // 2. If the type definition was updated successfully AND the unit has changed,
// convert the associated MeasurementValue entries. // convert associated MeasurementValue entries.
if (!conversionErrorOccurred && // The repository.updateMeasurementValue() method will trigger derived value recalculations.
originalType.unit != updatedType.unit && if (!mainOperationErrorOccurred && oldUnit != newUnit) {
originalType.inputType == InputFieldType.FLOAT && conversionProcessAttempted = true
updatedType.inputType == InputFieldType.FLOAT
) {
LogManager.i( LogManager.i(
TAG, TAG,
"Unit changed for FLOAT type ID ${originalType.id}. Converting values AFTER type definition update." "Unit changed for $typeKey (ID ${originalType.id}). Attempting to convert values."
) )
try { try {
// Fetch all values belonging to this type // Fetch all current values for this type to process them.
val valuesToConvert = repository.getValuesForType(originalType.id).first() val allValuesForThisType = repository.getValuesForType(originalType.id).first()
if (valuesToConvert.isNotEmpty()) { if (allValuesForThisType.isNotEmpty()) {
LogManager.d(TAG, "Found ${valuesToConvert.size} values of type ID ${originalType.id} to potentially convert.") LogManager.d(TAG, "Found ${allValuesForThisType.size} values of type $typeKey to potentially convert.")
val updatedValuesBatch = mutableListOf<MeasurementValue>()
valuesToConvert.forEach { valueToConvert -> for (valueToConvert in allValuesForThisType) {
valueToConvert.floatValue?.let { currentFloatVal -> val currentValue = valueToConvert.floatValue ?: continue // Skip if no float value
// Convert from the original unit of the values to the new target unit var convertedValue: Float? = null
val convertedFloat = Converters.convertFloatValueUnit(
value = currentFloatVal,
fromUnit = originalType.unit, // Important: Use the unit the values currently have
toUnit = updatedType.unit // The new target unit
)
if (convertedFloat != currentFloatVal) { // Add only if the value actually changes // --- Special handling for types like BODY_FAT, WATER, MUSCLE (Percent <-> Absolute Mass) ---
updatedValuesBatch.add(valueToConvert.copy(floatValue = convertedFloat)) if (typeKey == MeasurementTypeKey.BODY_FAT ||
valuesConvertedCount++ typeKey == MeasurementTypeKey.WATER ||
} typeKey == MeasurementTypeKey.MUSCLE
} ) {
// Fetch the global MeasurementType for WEIGHT to get its ID and current unit
val weightMeasurementTypeEntity = repository.getAllMeasurementTypes().first()
.find { it.key == MeasurementTypeKey.WEIGHT }
// Fetch the specific MeasurementValue for WEIGHT for the current measurement being processed
val weightValueObject = if (weightMeasurementTypeEntity != null) {
repository.getValuesForMeasurement(valueToConvert.measurementId).first()
.find { it.typeId == weightMeasurementTypeEntity.id }
} else { null }
if (weightValueObject?.floatValue == null || weightMeasurementTypeEntity == null) {
LogManager.w(TAG, "Weight data not found for measurement ${valueToConvert.measurementId}. Skipping PERCENT conversion for $typeKey (ID: ${valueToConvert.id}) value '${currentValue}'.")
continue // Skip conversion for this specific item
} }
if (updatedValuesBatch.isNotEmpty()) { val totalWeightRaw = weightValueObject.floatValue
LogManager.d(TAG, "Updating ${updatedValuesBatch.size} values in batch for type ID ${originalType.id}.") val totalWeightUnitGlobal = weightMeasurementTypeEntity.unit // Current global unit of WEIGHT type
updatedValuesBatch.forEach { repository.updateMeasurementValue(it) }
// Consider a repository.justUpdateMeasurementValueData(it) // Case 1: From PERCENT to an absolute weight unit (KG, LB, ST)
LogManager.d(TAG, "Batch update of ${updatedValuesBatch.size} values completed for type ID ${originalType.id}.") if (oldUnit == UnitType.PERCENT && newUnit.isWeightUnit()) {
val weightInKg = when(totalWeightUnitGlobal) {
UnitType.KG -> totalWeightRaw
UnitType.LB -> Converters.toKilogram(totalWeightRaw, WeightUnit.LB)
UnitType.ST -> Converters.toKilogram(totalWeightRaw, WeightUnit.ST)
else -> { LogManager.e(TAG, "Unsupported weight unit '$totalWeightUnitGlobal' for WEIGHT type."); null }
}
if (weightInKg != null) {
val absoluteValueInKg = (currentValue / 100.0f) * weightInKg
convertedValue = Converters.convertFloatValueUnit(absoluteValueInKg, UnitType.KG, newUnit)
}
}
// Case 2: From an absolute weight unit (KG, LB, ST) to PERCENT
else if (oldUnit.isWeightUnit() && newUnit == UnitType.PERCENT) {
val currentAbsoluteValueInKg = Converters.convertFloatValueUnit(currentValue, oldUnit, UnitType.KG)
val totalWeightInKg = when(totalWeightUnitGlobal) {
UnitType.KG -> totalWeightRaw
UnitType.LB -> Converters.toKilogram(totalWeightRaw, WeightUnit.LB)
UnitType.ST -> Converters.toKilogram(totalWeightRaw, WeightUnit.ST)
else -> { LogManager.e(TAG, "Unsupported weight unit '$totalWeightUnitGlobal' for WEIGHT type."); null }
}
if (currentAbsoluteValueInKg != null && totalWeightInKg != null && totalWeightInKg != 0.0f) {
convertedValue = (currentAbsoluteValueInKg / totalWeightInKg) * 100.0f
} else if (totalWeightInKg == 0.0f) {
LogManager.w(TAG, "Total weight is 0 for measurement ${valueToConvert.measurementId}. Cannot calculate $typeKey as PERCENT.")
convertedValue = 0.0f // Or null, or specific error handling
}
}
// Case 3: Between two absolute weight units (e.g. KG <-> LB for BODY_FAT itself if it was stored as absolute)
else if (oldUnit.isWeightUnit() && newUnit.isWeightUnit()) {
convertedValue = Converters.convertFloatValueUnit(currentValue, oldUnit, newUnit)
}
// Fallback for unhandled specific conversions for these types
else {
LogManager.w(TAG, "Unsupported unit conversion for $typeKey (ID: ${valueToConvert.id}) from $oldUnit to $newUnit. Value not changed.")
convertedValue = currentValue // Keep original value
}
}
// --- Standard conversion for other types (e.g., WEIGHT itself, WAIST, HIPS) ---
else {
convertedValue = Converters.convertFloatValueUnit(currentValue, oldUnit, newUnit)
}
// If value changed, update it. Repository's updateMeasurementValue will trigger recalculations.
if (convertedValue != null && CalculationUtil.roundTo(convertedValue) != CalculationUtil.roundTo(currentValue)) {
try {
repository.updateMeasurementValue(valueToConvert.copy(floatValue = CalculationUtil.roundTo(convertedValue)))
valuesSuccessfullyConvertedCount++
} catch (e: Exception) {
LogManager.e(TAG, "Error updating MeasurementValue (ID: ${valueToConvert.id}) for $typeKey.", e)
// Individual update error; loop continues.
}
} else if (convertedValue == null && oldUnit != newUnit) {
// Log if conversion was expected but resulted in null (e.g., unsupported path or missing prerequisites)
LogManager.e(TAG, "Conversion for $typeKey (ID: ${valueToConvert.id}) from $oldUnit to $newUnit resulted in null or was skipped. Original value '$currentValue' not changed.")
}
} // End forEach valueToConvert
LogManager.d(TAG, "$valuesSuccessfullyConvertedCount values successfully converted/updated for $typeKey (ID ${originalType.id}). Recalculation automatically triggered by repository for updated items.")
} else { } else {
LogManager.i(TAG, "No values required actual conversion or update for type ID ${originalType.id} after checking.") LogManager.i(TAG, "No values found for $typeKey (ID ${originalType.id}) to convert.")
}
} else {
LogManager.i(TAG, "No values found for type ID ${originalType.id} to convert.")
} }
} catch (e: Exception) { } catch (e: Exception) {
LogManager.e(TAG, "Error during value conversion/update for type ID ${originalType.id}", e) // Catch errors during the overall value fetching/processing logic (e.g., .first() on Flow fails)
conversionErrorOccurred = true LogManager.e(TAG, "Error during value conversion process for $typeKey (ID ${originalType.id})", e)
sharedViewModel.showSnackbar(
messageResId = R.string.measurement_type_update_error_conversion_failed,
formatArgs = listOf(originalType.name ?: originalType.key.toString())
)
// Optional: Consider reverting the MeasurementType update if value conversion fails (adds complexity).
} }
} else if (!conversionErrorOccurred && originalType.unit != updatedType.unit) { } else if (!mainOperationErrorOccurred && oldUnit != newUnit) {
// This block is for when unit changed, but the main conversion block wasn't entered.
// e.g., inputType is not FLOAT (though this is unlikely for types with units that change often).
// Recalculation for derived values in this scenario relies on other triggers or manual user actions.
LogManager.i( LogManager.i(
TAG, TAG,
"Unit changed for type ID ${originalType.id}, but InputType is not FLOAT or previous type update failed. " + "Unit changed for $typeKey (ID ${originalType.id}), but no direct value conversion was performed in the main block. " +
"No direct value conversion, but affected measurements will be flagged for recalculation." "Any necessary derived value recalculations depend on updates to their specific input values."
) )
} }
// 3. Show appropriate snackbar message. // 3. Show appropriate snackbar message.
if (!conversionErrorOccurred && showSnackbarMaster) { if (!mainOperationErrorOccurred && showSnackbarMaster) {
if (valuesConvertedCount > 0) { if (conversionProcessAttempted) { // If conversion was relevant and attempted
if (valuesSuccessfullyConvertedCount > 0) {
sharedViewModel.showSnackbar( sharedViewModel.showSnackbar(
messageResId = R.string.measurement_type_updated_and_values_converted_successfully, messageResId = R.string.measurement_type_updated_and_values_converted_successfully,
formatArgs = listOf( formatArgs = listOf(
updatedType.name ?: updatedType.key.toString(), finalTypeToUpdate.name ?: finalTypeToUpdate.key.toString(),
valuesConvertedCount.toString() valuesSuccessfullyConvertedCount.toString()
) )
) )
} else if (originalType.unit != updatedType.unit && (originalType.inputType == InputFieldType.FLOAT)) { } else { // Conversion attempted, but no values were actually changed/updated (e.g., all were same after conversion)
// Also show if unit changed but no values were converted (e.g., none existed or input type wasn't FLOAT but recalculation was still triggered)
sharedViewModel.showSnackbar( sharedViewModel.showSnackbar(
messageResId = R.string.measurement_type_updated_unit_changed_no_values_converted, // Or a more specific message messageResId = R.string.measurement_type_updated_unit_changed_no_values_converted,
formatArgs = listOf(updatedType.name ?: updatedType.key.toString()) formatArgs = listOf(finalTypeToUpdate.name ?: finalTypeToUpdate.key.toString())
) )
} else { // Generic success if no unit change or no conversion needed }
} else if (oldUnit == newUnit && !mainOperationErrorOccurred) {
// Case: Type updated (e.g. name, color changed) but unit was the same.
// Or unit changed but mainOperationErrorOccurred previously (though this snackbar won't show then).
sharedViewModel.showSnackbar( sharedViewModel.showSnackbar(
messageResId = R.string.measurement_type_updated_successfully, messageResId = R.string.measurement_type_updated_successfully, // Generic success for type definition update
formatArgs = listOf(updatedType.name ?: updatedType.key.toString()) formatArgs = listOf(finalTypeToUpdate.name ?: finalTypeToUpdate.key.toString())
) )
} }
} }