From acded1e0505b0281a50aa85aed88e7ac6aca2c08 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Wed, 13 Aug 2025 18:15:55 +0200 Subject: [PATCH] Enable measurement value conversion when unit changes --- .../com/health/openscale/core/data/Enums.kt | 3 + .../core/database/DatabaseRepository.kt | 3 + .../core/database/MeasurementValueDao.kt | 3 + .../health/openscale/core/utils/Converters.kt | 61 ++++++++ .../settings/MeasurementTypeDetailScreen.kt | 53 ++++++- .../ui/screen/settings/SettingsViewModel.kt | 144 ++++++++++++++++++ .../app/src/main/res/values-de/strings.xml | 5 + .../app/src/main/res/values/strings.xml | 7 +- 8 files changed, 276 insertions(+), 3 deletions(-) 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 b674d70e..549c0df9 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 @@ -178,8 +178,11 @@ enum class MeasurementTypeKey( enum class UnitType(val displayName: String) { KG("kg"), + LB("lb"), + ST("st"), PERCENT("%"), CM("cm"), + INCH("in"), KCAL("kcal"), NONE("") } 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 412da7bc..f1a05b53 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 @@ -179,6 +179,9 @@ class DatabaseRepository( fun getValuesForMeasurement(measurementId: Int): Flow> = measurementValueDao.getValuesForMeasurement(measurementId) + fun getValuesForType(typeId: Int): Flow> = + measurementValueDao.getValuesForType(typeId) + // --- Measurement Type Operations --- fun getAllMeasurementTypes(): Flow> = measurementTypeDao.getAll() diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt index 6db13b6b..5f76175f 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementValueDao.kt @@ -40,4 +40,7 @@ interface MeasurementValueDao { @Query("SELECT * FROM MeasurementValue WHERE measurementId = :measurementId") fun getValuesForMeasurement(measurementId: Int): Flow> + + @Query("SELECT * FROM MeasurementValue WHERE typeId = :typeId") + fun getValuesForType(typeId: Int): Flow> } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt index 45e54317..e3d34d32 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/Converters.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.utils import com.health.openscale.core.data.MeasureUnit +import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit @@ -174,4 +175,64 @@ object Converters { toInt32Be(data, 0, value) return data } + + /** + * Converts a Float value from one UnitType to another, if a conversion is defined. + * Returns the original value if no conversion is applicable or units are the same. + * + * @param value The float value to convert. + * @param fromUnit The original UnitType of the value. + * @param toUnit The target UnitType for the value. + * @return The converted float value, or the original value if no conversion is done. + */ + @JvmStatic + fun convertFloatValueUnit(value: Float, fromUnit: UnitType, toUnit: UnitType): Float { + if (fromUnit == toUnit) return value + + // KG -> Andere Gewichtseinheiten + if (fromUnit == UnitType.KG) { + return when (toUnit) { + UnitType.LB -> fromKilogram(value, WeightUnit.LB) + UnitType.ST -> fromKilogram(value, WeightUnit.ST) + else -> value // Keine Umrechnung zu anderen Typen von KG aus + } + } + // LB -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel) + if (fromUnit == UnitType.LB) { + val kgValue = toKilogram(value, WeightUnit.LB) + return when (toUnit) { + UnitType.KG -> kgValue + UnitType.ST -> fromKilogram(kgValue, WeightUnit.ST) + else -> value + } + } + // ST -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel) + if (fromUnit == UnitType.ST) { + val kgValue = toKilogram(value, WeightUnit.ST) + return when (toUnit) { + UnitType.KG -> kgValue + UnitType.LB -> fromKilogram(kgValue, WeightUnit.LB) + else -> value + } + } + + // CM -> Andere Längeneinheiten + if (fromUnit == UnitType.CM) { + return when (toUnit) { + UnitType.INCH -> fromCentimeter(value, MeasureUnit.INCH) + else -> value + } + } + + if (fromUnit == UnitType.INCH) { + val cmValue = toCentimeter(value, MeasureUnit.INCH) + return when (toUnit) { + UnitType.CM -> cmValue + else -> value + } + } + + return value + } + } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt index d8e42d72..2c328829 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox @@ -48,6 +49,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -76,6 +78,7 @@ import com.health.openscale.ui.screen.SharedViewModel import com.health.openscale.ui.screen.dialog.ColorPickerDialog import com.health.openscale.ui.screen.dialog.IconPickerDialog import com.health.openscale.ui.screen.dialog.getIconResIdByName +import kotlin.text.lowercase /** * Composable screen for creating or editing a [MeasurementType]. @@ -117,6 +120,9 @@ fun MeasurementTypeDetailScreen( var showColorPicker by remember { mutableStateOf(false) } var showIconPicker by remember { mutableStateOf(false) } + var showConfirmDialog by remember { mutableStateOf(false) } + var pendingUpdatedType by remember { mutableStateOf(null) } + val titleEdit = stringResource(R.string.measurement_type_detail_title_edit) val titleAdd = stringResource(R.string.measurement_type_detail_title_add) @@ -144,11 +150,20 @@ fun MeasurementTypeDetailScreen( ) if (isEdit) { - settingsViewModel.updateMeasurementType(updatedType) + val unitChanged = existingType!!.unit != updatedType.unit + val inputTypesAreFloat = existingType!!.inputType == InputFieldType.FLOAT && updatedType.inputType == InputFieldType.FLOAT + + if (unitChanged && inputTypesAreFloat) { + pendingUpdatedType = updatedType + showConfirmDialog = true + } else { + settingsViewModel.updateMeasurementType(updatedType) + navController.popBackStack() + } } else { settingsViewModel.addMeasurementType(updatedType) + navController.popBackStack() } - navController.popBackStack() } else { Toast.makeText(context, R.string.toast_enter_valid_data, Toast.LENGTH_SHORT).show() } @@ -156,6 +171,40 @@ fun MeasurementTypeDetailScreen( ) } + if (showConfirmDialog && existingType != null && pendingUpdatedType != null) { + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + title = { Text(stringResource(R.string.measurement_type_dialog_confirm_unit_change_title)) }, + text = { + Text( + stringResource( + R.string.measurement_type_dialog_confirm_unit_change_message, + existingType!!.getDisplayName(context), + existingType!!.unit.name.lowercase().replaceFirstChar { it.uppercase() }, + pendingUpdatedType!!.unit.name.lowercase().replaceFirstChar { it.uppercase() } + ) + ) + }, + confirmButton = { + TextButton(onClick = { + settingsViewModel.updateMeasurementTypeAndConvertDataViewModelCentric( + originalType = existingType!!, + updatedType = pendingUpdatedType!! + ) + showConfirmDialog = false + navController.popBackStack() + }) { + Text(stringResource(R.string.confirm_button)) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmDialog = false }) { + Text(stringResource(R.string.cancel_button)) + } + } + ) + } + Column( modifier = Modifier .padding(16.dp) 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 ba887708..1011eb10 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 @@ -19,6 +19,7 @@ package com.health.openscale.ui.screen.settings import android.content.ContentResolver import android.net.Uri +import androidx.compose.material3.SnackbarDuration import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.doyaaaaaken.kotlincsv.dsl.csvReader @@ -31,6 +32,7 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.model.MeasurementWithValues +import com.health.openscale.core.utils.Converters import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.screen.SharedViewModel import kotlinx.coroutines.Dispatchers @@ -1141,6 +1143,148 @@ class SettingsViewModel( } } + /** + * Updates an existing measurement type in the database. + * If the unit has changed for a FLOAT type, it will also convert associated measurement values. + * This version performs operations sequentially without a single overarching repository transaction + * and relies on the ViewModel for orchestration. + * + * @param originalType The MeasurementType as it was BEFORE any edits in the UI. + * Crucially contains the original unit and ID. + * @param updatedType The MeasurementType object with potentially updated information from the UI + * (new name, new unit, new color, etc.). + * @param showSnackbarMaster Boolean to control if a snackbar is shown after the entire operation. + * Individual snackbars for errors might still appear. + */ + fun updateMeasurementTypeAndConvertDataViewModelCentric( + originalType: MeasurementType, + updatedType: MeasurementType, + showSnackbarMaster: Boolean = true + ) { + viewModelScope.launch { + LogManager.i( + TAG, + "ViewModelCentric Update: Type ID ${originalType.id}. Original Unit: ${originalType.unit}, New Unit: ${updatedType.unit}" + ) + + var conversionErrorOccurred = false + var valuesConvertedCount = 0 + + if (originalType.unit != updatedType.unit && originalType.inputType == InputFieldType.FLOAT && updatedType.inputType == InputFieldType.FLOAT) { + LogManager.i( + TAG, + "Unit changed for FLOAT type ID ${originalType.id} from ${originalType.unit} to ${updatedType.unit}. Converting values." + ) + + try { + val valuesToConvert = repository.getValuesForType(originalType.id).first() // .first() um den aktuellen Wert des Flows zu erhalten + + if (valuesToConvert.isNotEmpty()) { + LogManager.d(TAG, "Found ${valuesToConvert.size} values of type ID ${originalType.id} to potentially convert.") + val updatedValuesBatch = mutableListOf() + + valuesToConvert.forEach { valueToConvert -> + valueToConvert.floatValue?.let { currentFloatVal -> + val convertedFloat = Converters.convertFloatValueUnit( + value = currentFloatVal, + fromUnit = originalType.unit, + toUnit = updatedType.unit + ) + + if (convertedFloat != currentFloatVal) { + updatedValuesBatch.add(valueToConvert.copy(floatValue = convertedFloat)) + valuesConvertedCount++ + } + } + } + + if (updatedValuesBatch.isNotEmpty()) { + LogManager.d(TAG, "Updating ${updatedValuesBatch.size} values in batch for type ID ${originalType.id}.") + // Aktualisiere jeden Wert einzeln. Dein Repository.updateMeasurementValue + // stößt die Neuberechnung der abgeleiteten Werte an. + updatedValuesBatch.forEach { repository.updateMeasurementValue(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.") + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during value conversion/update for type ID ${originalType.id}", e) + conversionErrorOccurred = true + // Verwende hier getDisplayName vom originalType, da updatedType möglicherweise noch nicht committet wurde. + sharedViewModel.showSnackbar( + messageResId = R.string.measurement_type_update_error_conversion_failed, + formatArgs = listOf(originalType.name ?: originalType.key.toString()) + ) + } + } else if (originalType.unit != updatedType.unit) { + LogManager.i( + TAG, + "Unit changed for type ID ${originalType.id}, but InputType is not FLOAT (Original: ${originalType.inputType}, Updated: ${updatedType.inputType}). No value conversion performed." + ) + } + + if (!conversionErrorOccurred) { + try { + val finalTypeToUpdate = MeasurementType( + id = originalType.id, + key = originalType.key, + name = updatedType.name, + color = updatedType.color, + icon = updatedType.icon, + unit = updatedType.unit, + inputType = updatedType.inputType, + displayOrder = originalType.displayOrder, + isDerived = originalType.isDerived, + isEnabled = updatedType.isEnabled, + isPinned = updatedType.isPinned, + isOnRightYAxis = updatedType.isOnRightYAxis + ) + + repository.updateMeasurementType(finalTypeToUpdate) + LogManager.i( + TAG, + "MeasurementType (ID: ${originalType.id}) updated successfully to new unit '${finalTypeToUpdate.unit}'." + ) + + if (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()) // Context für getDisplayName wäre besser + ) + } else if (originalType.unit != updatedType.unit && originalType.inputType == InputFieldType.FLOAT) { + sharedViewModel.showSnackbar( + messageResId = R.string.measurement_type_updated_unit_changed_no_values_converted, + formatArgs = listOf(updatedType.name ?: updatedType.key.toString()) // Context für getDisplayName wäre besser + ) + } + else { + sharedViewModel.showSnackbar( + messageResId = R.string.measurement_type_updated_successfully, + formatArgs = listOf(updatedType.name ?: updatedType.key.toString()) // Context für getDisplayName wäre besser + ) + } + } + // sharedViewModel.refreshMeasurementTypes() // Optional, wenn die Liste sich nicht automatisch aktualisiert + } catch (e: Exception) { + LogManager.e(TAG, "Error updating MeasurementType (ID: ${originalType.id}) itself", e) + sharedViewModel.showSnackbar( + messageResId = R.string.measurement_type_updated_error, + formatArgs = listOf(originalType.name ?: originalType.key.toString()) // Context für getDisplayName wäre besser + ) + } + } else { + LogManager.w( + TAG, + "Skipped updating MeasurementType definition (ID: ${originalType.id}) due to prior value conversion error." + ) + } + } + } + /** * Updates an existing measurement type in the database. * This operation is performed in a background coroutine. diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 2b082243..28d71569 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -152,6 +152,11 @@ Fehler beim Löschen der Messart \'%1$s\'. Messart \'%1$s\' erfolgreich aktualisiert. Fehler beim Aktualisieren der Messart \'%1$s\'. + Typ \'%1$s\' aktualisiert und %2$s Wert(e) erfolgreich konvertiert. + Fehler beim Konvertieren der Werte für Typ \'%1$s\'. Die Typdefinition wurde nicht aktualisiert. + Typ \'%1$s\' aktualisiert. Einheit geändert, aber es mussten keine vorhandenen Werte konvertiert werden. + Einheitenänderung bestätigen + Wenn Sie die Einheit für \'%1$s\' von %2$s auf %3$s ändern, werden alle vorhandenen Datenpunkte konvertiert. Dieser Vorgang kann einige Zeit dauern und kann nicht einfach rückgängig gemacht werden. Möchten Sie fortfahren? Verbindung zur Waage fehlgeschlagen, bitte stellen Sie sicher, dass sie eingeschaltet ist. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index b09f8257..2926e415 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -151,8 +151,13 @@ Error adding measurement type \'%1$s\'. Measurement type \'%1$s\' deleted successfully. Error deleting measurement type \'%1$s\'. - Measurement type \'%1$s\' updated successfully. Error updating measurement type \'%1$s\'. + Measurement type \'%1$s\' updated successfully. + Type \'%1$s\' updated and %2$s value(s) converted successfully. + Error converting values for type \'%1$s\'. The type definition was not updated. + Type \'%1$s\' updated. Unit changed, but no existing values required conversion. + Confirm Unit Change + Changing the unit for \'%1$s\' from %2$s to %3$s will convert all existing data points. This action may take some time and cannot be easily undone. Do you want to proceed? Could not connect to scale, please ensure it is on.