diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index 24369e58..aee1ed86 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -153,9 +153,9 @@ enum class MeasurementTypeKey( ) { 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)), - BODY_FAT(3, R.string.measurement_type_body_fat, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), - WATER(4, R.string.measurement_type_water, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), - MUSCLE(5, R.string.measurement_type_muscle, 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, UnitType.KG, UnitType.LB, UnitType.ST), 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)), 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)), @@ -189,7 +189,11 @@ enum class UnitType(val displayName: String) { CM("cm"), INCH("in"), KCAL("kcal"), - NONE("") + NONE(""); + + fun isWeightUnit(): Boolean { + return this == KG || this == LB || this == ST + } } enum class InputFieldType { diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt index 13da314e..93440852 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt @@ -272,7 +272,7 @@ class DatabaseRepository( } } else { // 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.floatValue != roundedValue) { measurementValueDao.update(existingDerivedValueObject.copy(floatValue = roundedValue)) @@ -550,12 +550,5 @@ class DatabaseRepository( fatPercentage } } - - /** - * Rounds a float value to two decimal places. - */ - private fun roundTo(value: Float): Float { - return (value * 100).toInt() / 100.0f - } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt index e99273c0..3ef07167 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt @@ -39,6 +39,13 @@ object CalculationUtil { 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 + } } /** diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt index feea3c8f..c646168c 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt @@ -17,13 +17,16 @@ */ package com.health.openscale.ui.screen.dialog +import androidx.compose.animation.core.copy import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.material3.AlertDialog import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text 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.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.health.openscale.R import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.UnitType @Composable fun NumberInputDialog( title: String, initialValue: String, inputType: InputFieldType, + unit: UnitType, iconRes: Int, color: Color, onDismiss: () -> Unit, @@ -112,26 +119,36 @@ fun NumberInputDialog( } ), trailingIcon = { - if (inputType == InputFieldType.INT || inputType == InputFieldType.FLOAT) { - Column { - Icon( - imageVector = Icons.Default.KeyboardArrowUp, - contentDescription = stringResource(R.string.trend_increased_desc), - modifier = Modifier - .size(24.dp) - .clickable { - value = incrementValue(value, inputType) - } - ) - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.trend_decreased_desc), - modifier = Modifier - .size(24.dp) - .clickable { - value = decrementValue(value, inputType) - } - ) + 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) { + Column { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.trend_increased_desc), + modifier = Modifier + .size(24.dp) + .clickable { + value = incrementValue(value, inputType) + } + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.trend_decreased_desc), + modifier = Modifier + .size(24.dp) + .clickable { + value = decrementValue(value, inputType) + } + ) + } } } }, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt index 7b6a2002..1630d647 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt @@ -372,6 +372,7 @@ fun MeasurementDetailScreen( title = dialogTitle, initialValue = initialDialogValue, inputType = currentType.inputType, + unit = currentType.unit, iconRes = typeIconRes, color = typeColor, onDismiss = { dialogTargetType = null }, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt index ca1b32c2..66a14f37 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt @@ -30,8 +30,11 @@ import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeKey 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.WeightUnit 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.LogManager import com.health.openscale.ui.screen.SharedViewModel @@ -1158,28 +1161,31 @@ class SettingsViewModel( */ fun updateMeasurementTypeAndConvertDataViewModelCentric( 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 ) { viewModelScope.launch { + val typeKey = originalType.key + val oldUnit = originalType.unit + val newUnit = updatedType.unit + LogManager.i( 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 valuesConvertedCount = 0 + var mainOperationErrorOccurred = false + var conversionProcessAttempted = false + var valuesSuccessfullyConvertedCount = 0 - // 1. First, 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. + // 1. Update the MeasurementType definition in the database. val finalTypeToUpdate = MeasurementType( id = originalType.id, - key = originalType.key, // Key should be immutable for an existing type + key = typeKey, name = updatedType.name, color = updatedType.color, icon = updatedType.icon, - unit = updatedType.unit, // Crucial: The new unit + unit = newUnit, // Apply the new unit inputType = updatedType.inputType, displayOrder = originalType.displayOrder, isDerived = originalType.isDerived, @@ -1192,101 +1198,162 @@ class SettingsViewModel( repository.updateMeasurementType(finalTypeToUpdate) LogManager.i( 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) { - 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( messageResId = R.string.measurement_type_updated_error, - // Consider using context.getString for display names if not available in originalType - formatArgs = listOf(originalType.name ?: originalType.key.toString()) + formatArgs = listOf(originalType.name ?: typeKey.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, - // convert the associated MeasurementValue entries. - if (!conversionErrorOccurred && - originalType.unit != updatedType.unit && - originalType.inputType == InputFieldType.FLOAT && - updatedType.inputType == InputFieldType.FLOAT - ) { + // 2. If the type definition was updated successfully AND the unit has changed, + // convert associated MeasurementValue entries. + // The repository.updateMeasurementValue() method will trigger derived value recalculations. + if (!mainOperationErrorOccurred && oldUnit != newUnit) { + conversionProcessAttempted = true LogManager.i( 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 { - // Fetch all values belonging to this type - val valuesToConvert = repository.getValuesForType(originalType.id).first() + // Fetch all current values for this type to process them. + val allValuesForThisType = repository.getValuesForType(originalType.id).first() - if (valuesToConvert.isNotEmpty()) { - LogManager.d(TAG, "Found ${valuesToConvert.size} values of type ID ${originalType.id} to potentially convert.") - val updatedValuesBatch = mutableListOf() + if (allValuesForThisType.isNotEmpty()) { + LogManager.d(TAG, "Found ${allValuesForThisType.size} values of type $typeKey to potentially convert.") - valuesToConvert.forEach { valueToConvert -> - valueToConvert.floatValue?.let { currentFloatVal -> - // Convert from the original unit of the values to the new target unit - val convertedFloat = Converters.convertFloatValueUnit( - value = currentFloatVal, - fromUnit = originalType.unit, // Important: Use the unit the values currently have - toUnit = updatedType.unit // The new target unit - ) + for (valueToConvert in allValuesForThisType) { + val currentValue = valueToConvert.floatValue ?: continue // Skip if no float value + var convertedValue: Float? = null - if (convertedFloat != currentFloatVal) { // Add only if the value actually changes - updatedValuesBatch.add(valueToConvert.copy(floatValue = convertedFloat)) - valuesConvertedCount++ + // --- Special handling for types like BODY_FAT, WATER, MUSCLE (Percent <-> Absolute Mass) --- + if (typeKey == MeasurementTypeKey.BODY_FAT || + 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 + } + + val totalWeightRaw = weightValueObject.floatValue + val totalWeightUnitGlobal = weightMeasurementTypeEntity.unit // Current global unit of WEIGHT type + + // Case 1: From PERCENT to an absolute weight unit (KG, LB, ST) + 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.") - if (updatedValuesBatch.isNotEmpty()) { - LogManager.d(TAG, "Updating ${updatedValuesBatch.size} values in batch for type ID ${originalType.id}.") - updatedValuesBatch.forEach { repository.updateMeasurementValue(it) } - // Consider a repository.justUpdateMeasurementValueData(it) - LogManager.d(TAG, "Batch update of ${updatedValuesBatch.size} values completed for type ID ${originalType.id}.") - } else { - LogManager.i(TAG, "No values required actual conversion or update for type ID ${originalType.id} after checking.") - } } else { - LogManager.i(TAG, "No values found for type ID ${originalType.id} to convert.") + LogManager.i(TAG, "No values found for $typeKey (ID ${originalType.id}) to convert.") } } catch (e: Exception) { - LogManager.e(TAG, "Error during value conversion/update for type ID ${originalType.id}", e) - conversionErrorOccurred = true - 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). + // Catch errors during the overall value fetching/processing logic (e.g., .first() on Flow fails) + LogManager.e(TAG, "Error during value conversion process for $typeKey (ID ${originalType.id})", e) } - } 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( TAG, - "Unit changed for type ID ${originalType.id}, but InputType is not FLOAT or previous type update failed. " + - "No direct value conversion, but affected measurements will be flagged for recalculation." + "Unit changed for $typeKey (ID ${originalType.id}), but no direct value conversion was performed in the main block. " + + "Any necessary derived value recalculations depend on updates to their specific input values." ) } // 3. Show appropriate snackbar message. - if (!conversionErrorOccurred && showSnackbarMaster) { - if (valuesConvertedCount > 0) { - sharedViewModel.showSnackbar( - messageResId = R.string.measurement_type_updated_and_values_converted_successfully, - formatArgs = listOf( - updatedType.name ?: updatedType.key.toString(), - valuesConvertedCount.toString() + if (!mainOperationErrorOccurred && showSnackbarMaster) { + if (conversionProcessAttempted) { // If conversion was relevant and attempted + if (valuesSuccessfullyConvertedCount > 0) { + sharedViewModel.showSnackbar( + messageResId = R.string.measurement_type_updated_and_values_converted_successfully, + formatArgs = listOf( + finalTypeToUpdate.name ?: finalTypeToUpdate.key.toString(), + valuesSuccessfullyConvertedCount.toString() + ) ) - ) - } else if (originalType.unit != updatedType.unit && (originalType.inputType == InputFieldType.FLOAT)) { - // 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) + } else { // Conversion attempted, but no values were actually changed/updated (e.g., all were same after conversion) + sharedViewModel.showSnackbar( + messageResId = R.string.measurement_type_updated_unit_changed_no_values_converted, + formatArgs = listOf(finalTypeToUpdate.name ?: finalTypeToUpdate.key.toString()) + ) + } + } 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( - messageResId = R.string.measurement_type_updated_unit_changed_no_values_converted, // Or a more specific message - formatArgs = listOf(updatedType.name ?: updatedType.key.toString()) - ) - } else { // Generic success if no unit change or no conversion needed - sharedViewModel.showSnackbar( - messageResId = R.string.measurement_type_updated_successfully, - formatArgs = listOf(updatedType.name ?: updatedType.key.toString()) + messageResId = R.string.measurement_type_updated_successfully, // Generic success for type definition update + formatArgs = listOf(finalTypeToUpdate.name ?: finalTypeToUpdate.key.toString()) ) } }