1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-17 22:11:35 +02:00

Refactor: Remove unit settings from user, use measurement type settings

This commit is contained in:
oliexdev
2025-08-13 19:21:24 +02:00
parent acded1e050
commit 22f5512b83
7 changed files with 295 additions and 266 deletions

View File

@@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 7, "version": 7,
"identityHash": "877ad250be34067d136497a388177415", "identityHash": "3eb1fa6c355e18713262b7da7d1ffbdd",
"entities": [ "entities": [
{ {
"tableName": "User", "tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `birthDate` INTEGER NOT NULL, `gender` TEXT NOT NULL, `heightCm` REAL NOT NULL, `activityLevel` TEXT NOT NULL, `scaleUnit` TEXT NOT NULL, `measureUnit` TEXT NOT NULL)", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `birthDate` INTEGER NOT NULL, `gender` TEXT NOT NULL, `heightCm` REAL NOT NULL, `activityLevel` TEXT NOT NULL)",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -43,18 +43,6 @@
"columnName": "activityLevel", "columnName": "activityLevel",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "measureUnit",
"columnName": "measureUnit",
"affinity": "TEXT",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@@ -298,7 +286,7 @@
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '877ad250be34067d136497a388177415')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3eb1fa6c355e18713262b7da7d1ffbdd')"
] ]
} }
} }

View File

@@ -144,35 +144,36 @@ enum class MeasureUnit {
enum class MeasurementTypeKey( enum class MeasurementTypeKey(
val id: Int, val id: Int,
@StringRes val localizedNameResId: Int // Added: Nullable resource ID for the name @StringRes val localizedNameResId: Int,
val allowedUnitTypes: List<UnitType>
) { ) {
WEIGHT(1, R.string.measurement_type_weight), WEIGHT(1, R.string.measurement_type_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST)),
BMI(2, R.string.measurement_type_bmi), BMI(2, R.string.measurement_type_bmi, listOf(UnitType.NONE)),
BODY_FAT(3, R.string.measurement_type_body_fat), BODY_FAT(3, R.string.measurement_type_body_fat, listOf(UnitType.PERCENT)),
WATER(4, R.string.measurement_type_water), WATER(4, R.string.measurement_type_water, listOf(UnitType.PERCENT)),
MUSCLE(5, R.string.measurement_type_muscle), MUSCLE(5, R.string.measurement_type_muscle, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB)),
LBM(6, R.string.measurement_type_lbm), LBM(6, R.string.measurement_type_lbm, listOf(UnitType.KG, UnitType.LB, UnitType.ST)),
BONE(7, R.string.measurement_type_bone), BONE(7, R.string.measurement_type_bone, listOf(UnitType.KG, UnitType.LB)),
WAIST(8, R.string.measurement_type_waist), WAIST(8, R.string.measurement_type_waist, listOf(UnitType.CM, UnitType.INCH)),
WHR(9, R.string.measurement_type_whr), WHR(9, R.string.measurement_type_whr, listOf(UnitType.NONE)),
WHTR(10, R.string.measurement_type_whtr), WHTR(10, R.string.measurement_type_whtr, listOf(UnitType.NONE)),
HIPS(11, R.string.measurement_type_hips), HIPS(11, R.string.measurement_type_hips, listOf(UnitType.CM, UnitType.INCH)),
VISCERAL_FAT(12, R.string.measurement_type_visceral_fat), VISCERAL_FAT(12, R.string.measurement_type_visceral_fat, listOf(UnitType.PERCENT, UnitType.NONE)),
CHEST(13, R.string.measurement_type_chest), CHEST(13, R.string.measurement_type_chest, listOf(UnitType.CM, UnitType.INCH)),
THIGH(14, R.string.measurement_type_thigh), THIGH(14, R.string.measurement_type_thigh, listOf(UnitType.CM, UnitType.INCH)),
BICEPS(15, R.string.measurement_type_biceps), BICEPS(15, R.string.measurement_type_biceps, listOf(UnitType.CM, UnitType.INCH)),
NECK(16, R.string.measurement_type_neck), NECK(16, R.string.measurement_type_neck, listOf(UnitType.CM, UnitType.INCH)),
CALIPER_1(17, R.string.measurement_type_caliper1), CALIPER_1(17, R.string.measurement_type_caliper1, listOf(UnitType.CM, UnitType.INCH)),
CALIPER_2(18, R.string.measurement_type_caliper2), CALIPER_2(18, R.string.measurement_type_caliper2, listOf(UnitType.CM, UnitType.INCH)),
CALIPER_3(19, R.string.measurement_type_caliper3), CALIPER_3(19, R.string.measurement_type_caliper3, listOf(UnitType.CM, UnitType.INCH)),
CALIPER(20, R.string.measurement_type_fat_caliper), CALIPER(20, R.string.measurement_type_fat_caliper, listOf(UnitType.PERCENT, UnitType.NONE)),
BMR(21, R.string.measurement_type_bmr), BMR(21, R.string.measurement_type_bmr, listOf(UnitType.KCAL)),
TDEE(22, R.string.measurement_type_tdee), TDEE(22, R.string.measurement_type_tdee, listOf(UnitType.KCAL)),
CALORIES(23, R.string.measurement_type_calories), CALORIES(23, R.string.measurement_type_calories, listOf(UnitType.KCAL)),
DATE(24, R.string.measurement_type_date), DATE(24, R.string.measurement_type_date, listOf(UnitType.NONE)),
TIME(25, R.string.measurement_type_time), TIME(25, R.string.measurement_type_time, listOf(UnitType.NONE)),
COMMENT(26, R.string.measurement_type_comment), COMMENT(26, R.string.measurement_type_comment, listOf(UnitType.NONE)),
CUSTOM(99, R.string.measurement_type_custom_default_name); CUSTOM(99, R.string.measurement_type_custom_default_name, UnitType.entries.toList());
} }

View File

@@ -27,7 +27,5 @@ data class User(
val birthDate: Long, val birthDate: Long,
val gender: GenderType, val gender: GenderType,
val heightCm: Float, val heightCm: Float,
val activityLevel: ActivityLevel, val activityLevel: ActivityLevel
var scaleUnit: WeightUnit,
var measureUnit: MeasureUnit
) )

View File

@@ -54,6 +54,7 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -80,16 +81,6 @@ import com.health.openscale.ui.screen.dialog.IconPickerDialog
import com.health.openscale.ui.screen.dialog.getIconResIdByName import com.health.openscale.ui.screen.dialog.getIconResIdByName
import kotlin.text.lowercase import kotlin.text.lowercase
/**
* Composable screen for creating or editing a [MeasurementType].
* It allows users to define the name, unit, input type, color, icon,
* and enabled/pinned status for a measurement type.
*
* @param navController NavController for navigating back after saving or cancelling.
* @param typeId The ID of the [MeasurementType] to edit. If -1, a new type is being created.
* @param sharedViewModel The [SharedViewModel] for accessing shared app state like existing measurement types and setting top bar properties.
* @param settingsViewModel The [SettingsViewModel] for performing add or update operations on measurement types.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MeasurementTypeDetailScreen( fun MeasurementTypeDetailScreen(
@@ -101,19 +92,44 @@ fun MeasurementTypeDetailScreen(
val context = LocalContext.current val context = LocalContext.current
val measurementTypes by sharedViewModel.measurementTypes.collectAsState() val measurementTypes by sharedViewModel.measurementTypes.collectAsState()
val existingType = remember(measurementTypes, typeId) { // Stores the original state of the measurement type before any UI changes.
// Crucial for the conversion logic to have the true original state.
val originalExistingType = remember(measurementTypes, typeId) {
measurementTypes.find { it.id == typeId } measurementTypes.find { it.id == typeId }
} }
val isEdit = typeId != -1 val isEdit = typeId != -1
var name by remember { mutableStateOf(existingType?.getDisplayName(context).orEmpty()) } // Determine the MeasurementTypeKey for the allowed units logic.
var selectedUnit by remember { mutableStateOf(existingType?.unit ?: UnitType.NONE) } // For new types, it's always CUSTOM; for existing types, it's the type's key.
var selectedInputType by remember { mutableStateOf(existingType?.inputType ?: InputFieldType.FLOAT) } val currentMeasurementTypeKey = remember(originalExistingType, isEdit) {
var selectedColor by remember { mutableStateOf(existingType?.color ?: 0xFF6200EE.toInt()) } // Default color if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM
var selectedIcon by remember { mutableStateOf(existingType?.icon ?: "ic_weight") } // Default icon else MeasurementTypeKey.CUSTOM
var isEnabled by remember { mutableStateOf(existingType?.isEnabled ?: true) } // Default to true for new types }
var isPinned by remember { mutableStateOf(existingType?.isPinned ?: false) } // Default to false for new types
var isOnRightYAxis by remember { mutableStateOf(existingType?.isOnRightYAxis ?: false) } // Get the list of allowed units based on the key.
val allowedUnitsForKey = remember(currentMeasurementTypeKey) {
currentMeasurementTypeKey.allowedUnitTypes
}
var name by remember { mutableStateOf(originalExistingType?.getDisplayName(context).orEmpty()) }
// Safely set selectedUnit. If the existing unit isn't allowed or if no existing unit,
// use the first allowed unit.
var selectedUnit by remember {
val initialUnit = originalExistingType?.unit
if (initialUnit != null && initialUnit in allowedUnitsForKey) {
mutableStateOf(initialUnit)
} else {
mutableStateOf(allowedUnitsForKey.firstOrNull() ?: UnitType.NONE)
}
}
var selectedInputType by remember { mutableStateOf(originalExistingType?.inputType ?: InputFieldType.FLOAT) }
var selectedColor by remember { mutableStateOf(originalExistingType?.color ?: 0xFF6200EE.toInt()) }
var selectedIcon by remember { mutableStateOf(originalExistingType?.icon ?: "ic_weight") }
var isEnabled by remember { mutableStateOf(originalExistingType?.isEnabled ?: true) }
var isPinned by remember { mutableStateOf(originalExistingType?.isPinned ?: false) }
var isOnRightYAxis by remember { mutableStateOf(originalExistingType?.isOnRightYAxis ?: false) }
var expandedUnit by remember { mutableStateOf(false) } var expandedUnit by remember { mutableStateOf(false) }
var expandedInputType by remember { mutableStateOf(false) } var expandedInputType by remember { mutableStateOf(false) }
@@ -126,42 +142,64 @@ fun MeasurementTypeDetailScreen(
val titleEdit = stringResource(R.string.measurement_type_detail_title_edit) val titleEdit = stringResource(R.string.measurement_type_detail_title_edit)
val titleAdd = stringResource(R.string.measurement_type_detail_title_add) val titleAdd = stringResource(R.string.measurement_type_detail_title_add)
// Determines if the unit dropdown should be enabled (i.e., if there's more than one allowed unit).
val unitDropdownEnabled by remember(allowedUnitsForKey) {
derivedStateOf { allowedUnitsForKey.size > 1 }
}
// Effect to re-evaluate and set selectedUnit if originalExistingType or allowedUnitsForKey change.
// This ensures selectedUnit is always valid.
LaunchedEffect(originalExistingType, allowedUnitsForKey) {
val currentUnitInExistingType = originalExistingType?.unit
if (currentUnitInExistingType != null && currentUnitInExistingType in allowedUnitsForKey) {
if (selectedUnit != currentUnitInExistingType) { // Only update if different to avoid recomposition loops
selectedUnit = currentUnitInExistingType
}
} else if (allowedUnitsForKey.isNotEmpty() && selectedUnit !in allowedUnitsForKey) {
selectedUnit = allowedUnitsForKey.first()
} else if (allowedUnitsForKey.isEmpty() && selectedUnit != UnitType.NONE) {
// This case should ideally not be reached if keys are well-defined.
selectedUnit = UnitType.NONE
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
sharedViewModel.setTopBarTitle( sharedViewModel.setTopBarTitle(if (isEdit) titleEdit else titleAdd)
if (isEdit) titleEdit
else titleAdd
)
sharedViewModel.setTopBarAction( sharedViewModel.setTopBarAction(
SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = { SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = {
if (name.isNotBlank()) { if (name.isNotBlank()) {
val updatedType = MeasurementType( // When creating the updatedType, use the key of the originalExistingType if it's an edit.
id = existingType?.id ?: 0, // Use 0 for new types, Room will autogenerate // For new types, it's MeasurementTypeKey.CUSTOM.
val finalKey = if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM else MeasurementTypeKey.CUSTOM
val currentUpdatedType = MeasurementType(
id = originalExistingType?.id ?: 0,
name = name, name = name,
icon = selectedIcon, icon = selectedIcon,
color = selectedColor, color = selectedColor,
unit = selectedUnit, unit = selectedUnit,
inputType = selectedInputType, inputType = selectedInputType,
displayOrder = existingType?.displayOrder ?: measurementTypes.size, displayOrder = originalExistingType?.displayOrder ?: measurementTypes.size,
isEnabled = isEnabled, isEnabled = isEnabled,
isPinned = isPinned, isPinned = isPinned,
key = existingType?.key ?: MeasurementTypeKey.CUSTOM, // New types are custom key = finalKey, // Use the correct key
isDerived = existingType?.isDerived ?: false, // New types are not derived by default isDerived = originalExistingType?.isDerived ?: false,
isOnRightYAxis = isOnRightYAxis isOnRightYAxis = isOnRightYAxis
) )
if (isEdit) { if (isEdit && originalExistingType != null) {
val unitChanged = existingType!!.unit != updatedType.unit val unitChanged = originalExistingType.unit != currentUpdatedType.unit
val inputTypesAreFloat = existingType!!.inputType == InputFieldType.FLOAT && updatedType.inputType == InputFieldType.FLOAT val inputTypesAreFloat = originalExistingType.inputType == InputFieldType.FLOAT && currentUpdatedType.inputType == InputFieldType.FLOAT
if (unitChanged && inputTypesAreFloat) { if (unitChanged && inputTypesAreFloat) {
pendingUpdatedType = updatedType pendingUpdatedType = currentUpdatedType
showConfirmDialog = true showConfirmDialog = true
} else { } else {
settingsViewModel.updateMeasurementType(updatedType) settingsViewModel.updateMeasurementType(currentUpdatedType)
navController.popBackStack() navController.popBackStack()
} }
} else { } else {
settingsViewModel.addMeasurementType(updatedType) settingsViewModel.addMeasurementType(currentUpdatedType)
navController.popBackStack() navController.popBackStack()
} }
} else { } else {
@@ -171,7 +209,7 @@ fun MeasurementTypeDetailScreen(
) )
} }
if (showConfirmDialog && existingType != null && pendingUpdatedType != null) { if (showConfirmDialog && originalExistingType != null && pendingUpdatedType != null) {
AlertDialog( AlertDialog(
onDismissRequest = { showConfirmDialog = false }, onDismissRequest = { showConfirmDialog = false },
title = { Text(stringResource(R.string.measurement_type_dialog_confirm_unit_change_title)) }, title = { Text(stringResource(R.string.measurement_type_dialog_confirm_unit_change_title)) },
@@ -179,28 +217,24 @@ fun MeasurementTypeDetailScreen(
Text( Text(
stringResource( stringResource(
R.string.measurement_type_dialog_confirm_unit_change_message, R.string.measurement_type_dialog_confirm_unit_change_message,
existingType!!.getDisplayName(context), originalExistingType.getDisplayName(context),
existingType!!.unit.name.lowercase().replaceFirstChar { it.uppercase() }, originalExistingType.unit.displayName.lowercase().replaceFirstChar { it.uppercase() },
pendingUpdatedType!!.unit.name.lowercase().replaceFirstChar { it.uppercase() } pendingUpdatedType!!.unit.displayName.lowercase().replaceFirstChar { it.uppercase() }
) )
) )
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
settingsViewModel.updateMeasurementTypeAndConvertDataViewModelCentric( settingsViewModel.updateMeasurementTypeAndConvertDataViewModelCentric(
originalType = existingType!!, originalType = originalExistingType,
updatedType = pendingUpdatedType!! updatedType = pendingUpdatedType!!
) )
showConfirmDialog = false showConfirmDialog = false
navController.popBackStack() navController.popBackStack()
}) { }) { Text(stringResource(R.string.confirm_button)) }
Text(stringResource(R.string.confirm_button))
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) { TextButton(onClick = { showConfirmDialog = false }) { Text(stringResource(R.string.cancel_button)) }
Text(stringResource(R.string.cancel_button))
}
} }
) )
} }
@@ -222,12 +256,14 @@ fun MeasurementTypeDetailScreen(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text(stringResource(R.string.measurement_type_label_name)) }, label = { Text(stringResource(R.string.measurement_type_label_name)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
// Name field is editable for new types or existing CUSTOM types.
// For predefined types, the name is typically not user-editable.
enabled = !isEdit || (originalExistingType?.key == MeasurementTypeKey.CUSTOM)
) )
// Color Selector
OutlinedTextField( OutlinedTextField(
value = String.format("#%06X", 0xFFFFFF and selectedColor), // Display color hex string value = String.format("#%06X", 0xFFFFFF and selectedColor),
onValueChange = {}, // Read-only onValueChange = {}, // Read-only
label = { Text(stringResource(R.string.measurement_type_label_color)) }, label = { Text(stringResource(R.string.measurement_type_label_color)) },
modifier = Modifier modifier = Modifier
@@ -241,21 +277,20 @@ fun MeasurementTypeDetailScreen(
.size(24.dp) .size(24.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(selectedColor)) .background(Color(selectedColor))
.border(1.dp, Color.Gray, CircleShape) // Visually indicate the color .border(1.dp, Color.Gray, CircleShape)
) )
}, },
colors = TextFieldDefaults.colors( // Custom colors to make it look enabled despite being readOnly colors = TextFieldDefaults.colors(
disabledTextColor = LocalContentColor.current, disabledTextColor = LocalContentColor.current,
disabledIndicatorColor = MaterialTheme.colorScheme.outline, // Standard outline disabledIndicatorColor = MaterialTheme.colorScheme.outline,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, // Standard label color disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledContainerColor = Color.Transparent // No background fill disabledContainerColor = Color.Transparent
) )
) )
// Icon Selector
OutlinedTextField( OutlinedTextField(
value = selectedIcon, // Display selected icon name value = selectedIcon,
onValueChange = {}, // Read-only onValueChange = {}, // Read-only
label = { Text(stringResource(R.string.measurement_type_label_icon)) }, label = { Text(stringResource(R.string.measurement_type_label_icon)) },
modifier = Modifier modifier = Modifier
@@ -268,14 +303,13 @@ fun MeasurementTypeDetailScreen(
painter = runCatching { painter = runCatching {
painterResource(id = getIconResIdByName(selectedIcon)) painterResource(id = getIconResIdByName(selectedIcon))
}.getOrElse { }.getOrElse {
// Fallback icon if resource name is invalid or not found Icons.Filled.QuestionMark // Fallback icon
Icons.Filled.QuestionMark } as Painter,
} as Painter, // Cast is safe due to getOrElse structure
contentDescription = stringResource(R.string.content_desc_selected_icon_preview), contentDescription = stringResource(R.string.content_desc_selected_icon_preview),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
}, },
colors = TextFieldDefaults.colors( // Custom colors for consistent look colors = TextFieldDefaults.colors(
disabledTextColor = LocalContentColor.current, disabledTextColor = LocalContentColor.current,
disabledIndicatorColor = MaterialTheme.colorScheme.outline, disabledIndicatorColor = MaterialTheme.colorScheme.outline,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -284,38 +318,59 @@ fun MeasurementTypeDetailScreen(
) )
) )
// UnitType Dropdown if (unitDropdownEnabled) {
ExposedDropdownMenuBox( // UnitType Dropdown
expanded = expandedUnit, ExposedDropdownMenuBox(
onExpandedChange = { expandedUnit = !expandedUnit } expanded = expandedUnit && unitDropdownEnabled,
) { onExpandedChange = {
OutlinedTextField( if (unitDropdownEnabled) expandedUnit = !expandedUnit
readOnly = true, }
value = selectedUnit.name.lowercase().replaceFirstChar { it.uppercase() }, // Format for display
onValueChange = {},
label = { Text(stringResource(R.string.measurement_type_label_unit)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedUnit)
},
modifier = Modifier
.menuAnchor( // Required for ExposedDropdownMenu
type = MenuAnchorType.PrimaryNotEditable,
enabled = true
)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expandedUnit,
onDismissRequest = { expandedUnit = false }
) { ) {
UnitType.entries.forEach { unit -> OutlinedTextField(
DropdownMenuItem( readOnly = true,
text = { Text(unit.name.lowercase().replaceFirstChar { it.uppercase() }) }, value = selectedUnit.displayName.lowercase()
onClick = { .replaceFirstChar { it.uppercase() },
selectedUnit = unit onValueChange = {},
expandedUnit = false label = { Text(stringResource(R.string.measurement_type_label_unit)) },
trailingIcon = {
if (unitDropdownEnabled) {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedUnit)
} }
) },
modifier = Modifier
.menuAnchor(
type = MenuAnchorType.PrimaryNotEditable,
enabled = unitDropdownEnabled
)
.fillMaxWidth(),
colors = if (!unitDropdownEnabled) OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.38f
)
) else OutlinedTextFieldDefaults.colors()
)
if (unitDropdownEnabled) {
ExposedDropdownMenu(
expanded = expandedUnit,
onDismissRequest = { expandedUnit = false }
) {
allowedUnitsForKey.forEach { unit ->
DropdownMenuItem(
text = {
Text(
unit.displayName.lowercase()
.replaceFirstChar { it.uppercase() })
},
onClick = {
selectedUnit = unit
expandedUnit = false
}
)
}
}
} }
} }
} }
@@ -327,17 +382,12 @@ fun MeasurementTypeDetailScreen(
) { ) {
OutlinedTextField( OutlinedTextField(
readOnly = true, readOnly = true,
value = selectedInputType.name.lowercase().replaceFirstChar { it.uppercase() }, // Format for display value = selectedInputType.name.lowercase().replaceFirstChar { it.uppercase() },
onValueChange = {}, onValueChange = {},
label = { Text(stringResource(R.string.measurement_type_label_input_type)) }, label = { Text(stringResource(R.string.measurement_type_label_input_type)) },
trailingIcon = { trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedInputType) },
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedInputType)
},
modifier = Modifier modifier = Modifier
.menuAnchor( // Required for ExposedDropdownMenu .menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true)
type = MenuAnchorType.PrimaryNotEditable,
enabled = true
)
.fillMaxWidth() .fillMaxWidth()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
@@ -371,73 +421,52 @@ fun MeasurementTypeDetailScreen(
} }
} }
// Color Picker Dialog
if (showColorPicker) { if (showColorPicker) {
ColorPickerDialog( ColorPickerDialog(
currentColor = Color(selectedColor), currentColor = Color(selectedColor),
onColorSelected = { onColorSelected = { selectedColor = it.toArgb() },
selectedColor = it.toArgb()
// showColorPicker = false // Keep picker open until explicitly dismissed by user
},
onDismiss = { showColorPicker = false } onDismiss = { showColorPicker = false }
) )
} }
// Icon Picker Dialog
if (showIconPicker) { if (showIconPicker) {
IconPickerDialog( IconPickerDialog(
onIconSelected = { onIconSelected = {
selectedIcon = it selectedIcon = it
showIconPicker = false // Close picker after selection showIconPicker = false
}, },
onDismiss = { showIconPicker = false } onDismiss = { showIconPicker = false }
) )
} }
} }
/**
* A private composable function that creates a row styled like an [OutlinedTextField]
* but designed to hold a label and a custom control (e.g., a [Switch]).
*
* @param label The text to display as the label for this setting row.
* @param modifier Modifier for this composable.
* @param controlContent A composable lambda that defines the control to be placed on the right side of the row.
*/
@Composable @Composable
private fun OutlinedSettingRow( private fun OutlinedSettingRow(
label: String, label: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controlContent: @Composable () -> Unit controlContent: @Composable () -> Unit
) { ) {
Surface( // Surface for the border and background, mimicking OutlinedTextField Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = OutlinedTextFieldDefaults.MinHeight), // Minimum height similar to OutlinedTextField .heightIn(min = OutlinedTextFieldDefaults.MinHeight),
shape = OutlinedTextFieldDefaults.shape, // Shape similar to OutlinedTextField shape = OutlinedTextFieldDefaults.shape,
color = MaterialTheme.colorScheme.surface, // Background color (can be customized) color = MaterialTheme.colorScheme.surface,
border = BorderStroke( // Border border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline)
width = 1.dp, // OutlinedTextFieldDefaults.UnfocusedBorderThickness is internal, so using 1.dp
color = MaterialTheme.colorScheme.outline // Border color similar to OutlinedTextField
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( // Internal padding similar to OutlinedTextField .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
start = 16.dp, // Similar to OutlinedTextFieldTokens.InputLeadingPadding
end = 16.dp, // Similar to OutlinedTextFieldTokens.InputTrailingPadding
top = 8.dp, // Less top padding as the label is centered vertically
bottom = 8.dp
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween // Pushes label to start, control to end horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.bodyLarge, // Style for the "label" style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant // Color of the "label" color = MaterialTheme.colorScheme.onSurfaceVariant
) )
controlContent() // The Switch or other control is placed here controlContent()
} }
} }
} }

View File

@@ -18,25 +18,34 @@
package com.health.openscale.ui.screen.settings package com.health.openscale.ui.screen.settings
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
@@ -53,16 +62,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.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 androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController import androidx.navigation.NavController
import com.health.openscale.R import com.health.openscale.R
import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.ActivityLevel
import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.GenderType
import com.health.openscale.core.data.MeasureUnit import com.health.openscale.core.data.MeasureUnit
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.data.WeightUnit
import com.health.openscale.core.utils.Converters
import com.health.openscale.ui.screen.SharedViewModel import com.health.openscale.ui.screen.SharedViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.DateFormat import java.text.DateFormat
@@ -111,10 +124,11 @@ fun UserDetailScreen(
} }
} }
var gender by remember { mutableStateOf(user?.gender ?: GenderType.MALE) } var gender by remember { mutableStateOf(user?.gender ?: GenderType.MALE) }
var height by remember { mutableStateOf(user?.heightCm?.toString().orEmpty()) } var heightInputUnit by remember { mutableStateOf(UnitType.CM) }
var heightValueString by remember { mutableStateOf("") }
val heightUnitsOptions = listOf(UnitType.CM, UnitType.INCH)
var activityLevel by remember { mutableStateOf(user?.activityLevel ?: ActivityLevel.SEDENTARY) } var activityLevel by remember { mutableStateOf(user?.activityLevel ?: ActivityLevel.SEDENTARY) }
var scaleUnit by remember { mutableStateOf(user?.scaleUnit ?: WeightUnit.KG) }
var measureUnit by remember { mutableStateOf(user?.measureUnit ?: MeasureUnit.CM) }
val context = LocalContext.current val context = LocalContext.current
val dateFormatter = remember { val dateFormatter = remember {
@@ -125,8 +139,6 @@ fun UserDetailScreen(
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = birthDate) val datePickerState = rememberDatePickerState(initialSelectedDateMillis = birthDate)
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var activityLevelExpanded by remember { mutableStateOf(false) } var activityLevelExpanded by remember { mutableStateOf(false) }
var scaleUnitExpanded by remember { mutableStateOf(false) }
var measureUnitExpanded by remember { mutableStateOf(false) }
if (showDatePicker) { if (showDatePicker) {
DatePickerDialog( DatePickerDialog(
@@ -154,6 +166,24 @@ fun UserDetailScreen(
val editUserTitle = stringResource(R.string.user_detail_edit_user_title) val editUserTitle = stringResource(R.string.user_detail_edit_user_title)
val addUserTitle = stringResource(R.string.user_detail_add_user_title) val addUserTitle = stringResource(R.string.user_detail_add_user_title)
LaunchedEffect(user, heightInputUnit) {
user?.heightCm?.let { cmValue -> // user.heightCm ist die in der DB gespeicherte Höhe in CM
if (cmValue > 0f) {
heightValueString = if (heightInputUnit == UnitType.CM) {
String.format(Locale.US, "%.1f", cmValue)
} else { // heightInputUnit == UnitType.INCH
// Konvertiere den CM-Wert aus der DB in Zoll für die Anzeige
val inchesValue = Converters.convertFloatValueUnit(cmValue, UnitType.CM, UnitType.INCH)
String.format(Locale.US, "%.1f", inchesValue)
}
} else {
heightValueString = "" // Wenn keine valide gespeicherte Höhe, Feld leeren
}
} ?: run {
heightValueString = "" // Neuer User, Feld leer
}
}
// Effect to set the top bar title and save action. // Effect to set the top bar title and save action.
// This runs when userId changes or the screen is first composed. // This runs when userId changes or the screen is first composed.
LaunchedEffect(userId) { LaunchedEffect(userId) {
@@ -163,17 +193,25 @@ fun UserDetailScreen(
) )
sharedViewModel.setTopBarAction( sharedViewModel.setTopBarAction(
SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = { SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = {
val validHeight = height.toFloatOrNull() val validNumericHeight = heightValueString.toFloatOrNull()
if (name.isNotBlank() && validHeight != null) { var finalHeightCm: Float? = null
if (validNumericHeight != null && validNumericHeight > 0f) {
finalHeightCm = if (heightInputUnit == UnitType.CM) {
validNumericHeight // Wert ist bereits in CM
} else { // heightInputUnit == UnitType.INCH
// Konvertiere den in Zoll eingegebenen Wert zurück zu CM für die Speicherung
Converters.convertFloatValueUnit(validNumericHeight, UnitType.INCH, UnitType.CM)
}
}
if (name.isNotBlank() && finalHeightCm != null) {
val newUser = User( val newUser = User(
id = user?.id ?: 0, // Use existing ID if editing, or 0 for Room to auto-generate id = user?.id ?: 0, // Use existing ID if editing, or 0 for Room to auto-generate
name = name, name = name,
birthDate = birthDate, birthDate = birthDate,
gender = gender, gender = gender,
heightCm = validHeight, heightCm = finalHeightCm,
activityLevel = activityLevel, activityLevel = activityLevel
scaleUnit = scaleUnit,
measureUnit = measureUnit
) )
settingsViewModel.viewModelScope.launch { settingsViewModel.viewModelScope.launch {
if (isEdit) { if (isEdit) {
@@ -203,7 +241,7 @@ fun UserDetailScreen(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState), // Make the column scrollable .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OutlinedTextField( OutlinedTextField(
@@ -213,14 +251,59 @@ fun UserDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text(stringResource(id = R.string.user_detail_label_height))
OutlinedTextField( OutlinedTextField(
value = height, value = heightValueString,
onValueChange = { height = it }, onValueChange = { newValue ->
label = { Text(stringResource(id = R.string.user_detail_label_height_cm)) }, // "Height (cm)" val filteredValue = newValue.filter { it.isDigit() || it == '.' }
if (filteredValue.count { it == '.' } <= 1) {
heightValueString = filteredValue
}
},
label = { Text(stringResource(R.string.user_detail_label_height)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
singleLine = true,
trailingIcon = {
IconButton(onClick = {
val currentIndex = heightUnitsOptions.indexOf(heightInputUnit)
val nextIndex = (currentIndex + 1) % heightUnitsOptions.size
val newUnit = heightUnitsOptions[nextIndex]
val currentNumericValue = heightValueString.toFloatOrNull()
if (currentNumericValue != null && currentNumericValue > 0f) {
val convertedValue = Converters.convertFloatValueUnit(currentNumericValue, heightInputUnit, newUnit)
heightValueString = String.format(Locale.US, "%.1f", convertedValue)
} else {
heightValueString = ""
}
heightInputUnit = newUnit
}) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = heightInputUnit.displayName.uppercase(),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
)
Icon(
imageVector = Icons.Filled.UnfoldMore,
contentDescription = stringResource(R.string.user_detail_content_description_change_unit),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
) )
Text(stringResource(id = R.string.user_detail_label_gender)) // "Gender" Text(stringResource(id = R.string.user_detail_label_gender)) // "Gender"
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GenderType.entries.forEach { option -> GenderType.entries.forEach { option ->
@@ -275,74 +358,6 @@ fun UserDetailScreen(
} }
} }
ExposedDropdownMenuBox(
expanded = scaleUnitExpanded,
onExpandedChange = { scaleUnitExpanded = !scaleUnitExpanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = scaleUnit.toString(),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(id = R.string.user_detail_label_scale_unit)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = scaleUnitExpanded)
},
modifier = Modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = scaleUnitExpanded,
onDismissRequest = { scaleUnitExpanded = false }
) {
WeightUnit.entries.forEach { selectionOption ->
DropdownMenuItem(
text = { Text(selectionOption.toString()) },
onClick = {
scaleUnit = selectionOption
scaleUnitExpanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
ExposedDropdownMenuBox(
expanded = measureUnitExpanded,
onExpandedChange = { measureUnitExpanded = !measureUnitExpanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = measureUnit.toString(),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(id = R.string.user_detail_label_measure_unit)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = measureUnitExpanded)
},
modifier = Modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = measureUnitExpanded,
onDismissRequest = { measureUnitExpanded = false }
) {
MeasureUnit.entries.forEach { selectionOption ->
DropdownMenuItem(
text = { Text(selectionOption.toString()) },
onClick = {
measureUnit = selectionOption
measureUnitExpanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
Text(stringResource(id = R.string.user_detail_label_birth_date)) // "Birth Date" Text(stringResource(id = R.string.user_detail_label_birth_date)) // "Birth Date"
OutlinedTextField( OutlinedTextField(
value = dateFormatter.format(Date(birthDate)), value = dateFormatter.format(Date(birthDate)),

View File

@@ -48,18 +48,17 @@
<string name="user_updated_successfully">Benutzer %1$s erfolgreich aktualisiert.</string> <string name="user_updated_successfully">Benutzer %1$s erfolgreich aktualisiert.</string>
<string name="user_updated_error">Fehler beim Aktualisieren von Benutzer %1$s.</string> <string name="user_updated_error">Fehler beim Aktualisieren von Benutzer %1$s.</string>
<string name="user_detail_label_name">Name</string> <string name="user_detail_label_name">Name</string>
<string name="user_detail_label_height_cm">Größe (cm)</string> <string name="user_detail_label_height">Größe</string>
<string name="user_detail_label_gender">Geschlecht</string> <string name="user_detail_label_gender">Geschlecht</string>
<string name="user_detail_label_activity_level">Aktivitätslevel</string> <string name="user_detail_label_activity_level">Aktivitätslevel</string>
<string name="user_detail_label_birth_date">Geburtsdatum</string> <string name="user_detail_label_birth_date">Geburtsdatum</string>
<string name="user_detail_error_invalid_data">Bitte geben Sie gültige Daten ein</string> <string name="user_detail_error_invalid_data">Bitte geben Sie gültige Daten ein</string>
<string name="user_detail_label_scale_unit">Gewichtseinheit</string>
<string name="user_detail_label_measure_unit">Größeneinheit</string>
<string name="height_value_cm">%.1f cm</string> <string name="height_value_cm">%.1f cm</string>
<string name="user_settings_item_details_conditional">Alter: %1$d, Größe: %2$s</string> <string name="user_settings_item_details_conditional">Alter: %1$d, Größe: %2$s</string>
<string name="user_settings_content_description_edit">Benutzer bearbeiten</string> <string name="user_settings_content_description_edit">Benutzer bearbeiten</string>
<string name="user_settings_content_description_delete">Benutzer löschen</string> <string name="user_settings_content_description_delete">Benutzer löschen</string>
<string name="user_settings_content_description_add_user">Neuen Benutzer hinzufügen</string> <string name="user_settings_content_description_add_user">Neuen Benutzer hinzufügen</string>
<string name="user_detail_content_description_change_unit">Einheit ändern</string>
<!-- Messung CRUD & Operationen --> <!-- Messung CRUD & Operationen -->
<string name="success_measurement_updated">Messung erfolgreich aktualisiert</string> <string name="success_measurement_updated">Messung erfolgreich aktualisiert</string>

View File

@@ -49,18 +49,17 @@
<string name="user_updated_successfully">User %1$s updated successfully.</string> <string name="user_updated_successfully">User %1$s updated successfully.</string>
<string name="user_updated_error">Error updating user %1$s.</string> <string name="user_updated_error">Error updating user %1$s.</string>
<string name="user_detail_label_name">Name</string> <string name="user_detail_label_name">Name</string>
<string name="user_detail_label_height_cm">Height (cm)</string> <string name="user_detail_label_height">Height</string>
<string name="user_detail_label_gender">Gender</string> <string name="user_detail_label_gender">Gender</string>
<string name="user_detail_label_activity_level">Activity Level</string> <string name="user_detail_label_activity_level">Activity Level</string>
<string name="user_detail_label_birth_date">Birth Date</string> <string name="user_detail_label_birth_date">Birth Date</string>
<string name="user_detail_error_invalid_data">Please enter valid data</string> <string name="user_detail_error_invalid_data">Please enter valid data</string>
<string name="user_detail_label_scale_unit">Scale Unit</string>
<string name="user_detail_label_measure_unit">Measure Unit</string>
<string name="height_value_cm">%.1f cm</string> <string name="height_value_cm">%.1f cm</string>
<string name="user_settings_item_details_conditional">Age: %1$d, Height: %2$s</string> <string name="user_settings_item_details_conditional">Age: %1$d, Height: %2$s</string>
<string name="user_settings_content_description_edit">Edit user</string> <string name="user_settings_content_description_edit">Edit user</string>
<string name="user_settings_content_description_delete">Delete user</string> <string name="user_settings_content_description_delete">Delete user</string>
<string name="user_settings_content_description_add_user">Add new user</string> <string name="user_settings_content_description_add_user">Add new user</string>
<string name="user_detail_content_description_change_unit">Change unit</string>
<!-- Measurement CRUD & Operations --> <!-- Measurement CRUD & Operations -->
<string name="success_measurement_updated">Measurement successfully updated</string> <string name="success_measurement_updated">Measurement successfully updated</string>