diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json index 581b85cb..5aa07288 100644 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json +++ b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 7, - "identityHash": "877ad250be34067d136497a388177415", + "identityHash": "3eb1fa6c355e18713262b7da7d1ffbdd", "entities": [ { "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": [ { "fieldPath": "id", @@ -43,18 +43,6 @@ "columnName": "activityLevel", "affinity": "TEXT", "notNull": true - }, - { - "fieldPath": "scaleUnit", - "columnName": "scaleUnit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "measureUnit", - "columnName": "measureUnit", - "affinity": "TEXT", - "notNull": true } ], "primaryKey": { @@ -298,7 +286,7 @@ ], "setupQueries": [ "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')" ] } } \ No newline at end of file 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 549c0df9..18cb9604 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 @@ -144,35 +144,36 @@ enum class MeasureUnit { enum class MeasurementTypeKey( val id: Int, - @StringRes val localizedNameResId: Int // Added: Nullable resource ID for the name + @StringRes val localizedNameResId: Int, + val allowedUnitTypes: List ) { - WEIGHT(1, R.string.measurement_type_weight), - BMI(2, R.string.measurement_type_bmi), - BODY_FAT(3, R.string.measurement_type_body_fat), - WATER(4, R.string.measurement_type_water), - MUSCLE(5, R.string.measurement_type_muscle), - LBM(6, R.string.measurement_type_lbm), - BONE(7, R.string.measurement_type_bone), - WAIST(8, R.string.measurement_type_waist), - WHR(9, R.string.measurement_type_whr), - WHTR(10, R.string.measurement_type_whtr), - HIPS(11, R.string.measurement_type_hips), - VISCERAL_FAT(12, R.string.measurement_type_visceral_fat), - CHEST(13, R.string.measurement_type_chest), - THIGH(14, R.string.measurement_type_thigh), - BICEPS(15, R.string.measurement_type_biceps), - NECK(16, R.string.measurement_type_neck), - CALIPER_1(17, R.string.measurement_type_caliper1), - CALIPER_2(18, R.string.measurement_type_caliper2), - CALIPER_3(19, R.string.measurement_type_caliper3), - CALIPER(20, R.string.measurement_type_fat_caliper), - BMR(21, R.string.measurement_type_bmr), - TDEE(22, R.string.measurement_type_tdee), - CALORIES(23, R.string.measurement_type_calories), - DATE(24, R.string.measurement_type_date), - TIME(25, R.string.measurement_type_time), - COMMENT(26, R.string.measurement_type_comment), - CUSTOM(99, R.string.measurement_type_custom_default_name); + WEIGHT(1, R.string.measurement_type_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST)), + BMI(2, R.string.measurement_type_bmi, listOf(UnitType.NONE)), + BODY_FAT(3, R.string.measurement_type_body_fat, listOf(UnitType.PERCENT)), + WATER(4, R.string.measurement_type_water, listOf(UnitType.PERCENT)), + MUSCLE(5, R.string.measurement_type_muscle, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB)), + LBM(6, R.string.measurement_type_lbm, listOf(UnitType.KG, UnitType.LB, UnitType.ST)), + BONE(7, R.string.measurement_type_bone, listOf(UnitType.KG, UnitType.LB)), + WAIST(8, R.string.measurement_type_waist, listOf(UnitType.CM, UnitType.INCH)), + WHR(9, R.string.measurement_type_whr, listOf(UnitType.NONE)), + WHTR(10, R.string.measurement_type_whtr, listOf(UnitType.NONE)), + HIPS(11, R.string.measurement_type_hips, listOf(UnitType.CM, UnitType.INCH)), + VISCERAL_FAT(12, R.string.measurement_type_visceral_fat, listOf(UnitType.PERCENT, UnitType.NONE)), + CHEST(13, R.string.measurement_type_chest, listOf(UnitType.CM, UnitType.INCH)), + THIGH(14, R.string.measurement_type_thigh, listOf(UnitType.CM, UnitType.INCH)), + BICEPS(15, R.string.measurement_type_biceps, listOf(UnitType.CM, UnitType.INCH)), + NECK(16, R.string.measurement_type_neck, listOf(UnitType.CM, UnitType.INCH)), + CALIPER_1(17, R.string.measurement_type_caliper1, listOf(UnitType.CM, UnitType.INCH)), + CALIPER_2(18, R.string.measurement_type_caliper2, listOf(UnitType.CM, UnitType.INCH)), + CALIPER_3(19, R.string.measurement_type_caliper3, listOf(UnitType.CM, UnitType.INCH)), + CALIPER(20, R.string.measurement_type_fat_caliper, listOf(UnitType.PERCENT, UnitType.NONE)), + BMR(21, R.string.measurement_type_bmr, listOf(UnitType.KCAL)), + TDEE(22, R.string.measurement_type_tdee, listOf(UnitType.KCAL)), + CALORIES(23, R.string.measurement_type_calories, listOf(UnitType.KCAL)), + DATE(24, R.string.measurement_type_date, listOf(UnitType.NONE)), + TIME(25, R.string.measurement_type_time, listOf(UnitType.NONE)), + COMMENT(26, R.string.measurement_type_comment, listOf(UnitType.NONE)), + CUSTOM(99, R.string.measurement_type_custom_default_name, UnitType.entries.toList()); } diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/User.kt b/android_app/app/src/main/java/com/health/openscale/core/data/User.kt index 382f6adb..76444755 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/User.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/User.kt @@ -27,7 +27,5 @@ data class User( val birthDate: Long, val gender: GenderType, val heightCm: Float, - val activityLevel: ActivityLevel, - var scaleUnit: WeightUnit, - var measureUnit: MeasureUnit + val activityLevel: ActivityLevel ) \ 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 2c328829..e2284033 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 @@ -54,6 +54,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 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) @Composable fun MeasurementTypeDetailScreen( @@ -101,19 +92,44 @@ fun MeasurementTypeDetailScreen( val context = LocalContext.current 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 } } val isEdit = typeId != -1 - var name by remember { mutableStateOf(existingType?.getDisplayName(context).orEmpty()) } - var selectedUnit by remember { mutableStateOf(existingType?.unit ?: UnitType.NONE) } - var selectedInputType by remember { mutableStateOf(existingType?.inputType ?: InputFieldType.FLOAT) } - var selectedColor by remember { mutableStateOf(existingType?.color ?: 0xFF6200EE.toInt()) } // Default color - var selectedIcon by remember { mutableStateOf(existingType?.icon ?: "ic_weight") } // Default icon - 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) } + // Determine the MeasurementTypeKey for the allowed units logic. + // For new types, it's always CUSTOM; for existing types, it's the type's key. + val currentMeasurementTypeKey = remember(originalExistingType, isEdit) { + if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM + else MeasurementTypeKey.CUSTOM + } + + // 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 expandedInputType by remember { mutableStateOf(false) } @@ -126,42 +142,64 @@ fun MeasurementTypeDetailScreen( val titleEdit = stringResource(R.string.measurement_type_detail_title_edit) 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) { - sharedViewModel.setTopBarTitle( - if (isEdit) titleEdit - else titleAdd - ) + sharedViewModel.setTopBarTitle(if (isEdit) titleEdit else titleAdd) sharedViewModel.setTopBarAction( SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = { if (name.isNotBlank()) { - val updatedType = MeasurementType( - id = existingType?.id ?: 0, // Use 0 for new types, Room will autogenerate + // When creating the updatedType, use the key of the originalExistingType if it's an edit. + // 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, icon = selectedIcon, color = selectedColor, unit = selectedUnit, inputType = selectedInputType, - displayOrder = existingType?.displayOrder ?: measurementTypes.size, + displayOrder = originalExistingType?.displayOrder ?: measurementTypes.size, isEnabled = isEnabled, isPinned = isPinned, - key = existingType?.key ?: MeasurementTypeKey.CUSTOM, // New types are custom - isDerived = existingType?.isDerived ?: false, // New types are not derived by default + key = finalKey, // Use the correct key + isDerived = originalExistingType?.isDerived ?: false, isOnRightYAxis = isOnRightYAxis ) - if (isEdit) { - val unitChanged = existingType!!.unit != updatedType.unit - val inputTypesAreFloat = existingType!!.inputType == InputFieldType.FLOAT && updatedType.inputType == InputFieldType.FLOAT + if (isEdit && originalExistingType != null) { + val unitChanged = originalExistingType.unit != currentUpdatedType.unit + val inputTypesAreFloat = originalExistingType.inputType == InputFieldType.FLOAT && currentUpdatedType.inputType == InputFieldType.FLOAT if (unitChanged && inputTypesAreFloat) { - pendingUpdatedType = updatedType + pendingUpdatedType = currentUpdatedType showConfirmDialog = true } else { - settingsViewModel.updateMeasurementType(updatedType) + settingsViewModel.updateMeasurementType(currentUpdatedType) navController.popBackStack() } } else { - settingsViewModel.addMeasurementType(updatedType) + settingsViewModel.addMeasurementType(currentUpdatedType) navController.popBackStack() } } else { @@ -171,7 +209,7 @@ fun MeasurementTypeDetailScreen( ) } - if (showConfirmDialog && existingType != null && pendingUpdatedType != null) { + if (showConfirmDialog && originalExistingType != null && pendingUpdatedType != null) { AlertDialog( onDismissRequest = { showConfirmDialog = false }, title = { Text(stringResource(R.string.measurement_type_dialog_confirm_unit_change_title)) }, @@ -179,28 +217,24 @@ fun MeasurementTypeDetailScreen( 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() } + originalExistingType.getDisplayName(context), + originalExistingType.unit.displayName.lowercase().replaceFirstChar { it.uppercase() }, + pendingUpdatedType!!.unit.displayName.lowercase().replaceFirstChar { it.uppercase() } ) ) }, confirmButton = { TextButton(onClick = { settingsViewModel.updateMeasurementTypeAndConvertDataViewModelCentric( - originalType = existingType!!, + originalType = originalExistingType, updatedType = pendingUpdatedType!! ) showConfirmDialog = false navController.popBackStack() - }) { - Text(stringResource(R.string.confirm_button)) - } + }) { Text(stringResource(R.string.confirm_button)) } }, dismissButton = { - TextButton(onClick = { showConfirmDialog = false }) { - Text(stringResource(R.string.cancel_button)) - } + TextButton(onClick = { showConfirmDialog = false }) { Text(stringResource(R.string.cancel_button)) } } ) } @@ -222,12 +256,14 @@ fun MeasurementTypeDetailScreen( value = name, onValueChange = { name = it }, 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( - value = String.format("#%06X", 0xFFFFFF and selectedColor), // Display color hex string + value = String.format("#%06X", 0xFFFFFF and selectedColor), onValueChange = {}, // Read-only label = { Text(stringResource(R.string.measurement_type_label_color)) }, modifier = Modifier @@ -241,21 +277,20 @@ fun MeasurementTypeDetailScreen( .size(24.dp) .clip(CircleShape) .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, - disabledIndicatorColor = MaterialTheme.colorScheme.outline, // Standard outline - disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, // Standard label color + disabledIndicatorColor = MaterialTheme.colorScheme.outline, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledContainerColor = Color.Transparent // No background fill + disabledContainerColor = Color.Transparent ) ) - // Icon Selector OutlinedTextField( - value = selectedIcon, // Display selected icon name + value = selectedIcon, onValueChange = {}, // Read-only label = { Text(stringResource(R.string.measurement_type_label_icon)) }, modifier = Modifier @@ -268,14 +303,13 @@ fun MeasurementTypeDetailScreen( painter = runCatching { painterResource(id = getIconResIdByName(selectedIcon)) }.getOrElse { - // Fallback icon if resource name is invalid or not found - Icons.Filled.QuestionMark - } as Painter, // Cast is safe due to getOrElse structure + Icons.Filled.QuestionMark // Fallback icon + } as Painter, contentDescription = stringResource(R.string.content_desc_selected_icon_preview), modifier = Modifier.size(24.dp) ) }, - colors = TextFieldDefaults.colors( // Custom colors for consistent look + colors = TextFieldDefaults.colors( disabledTextColor = LocalContentColor.current, disabledIndicatorColor = MaterialTheme.colorScheme.outline, disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, @@ -284,38 +318,59 @@ fun MeasurementTypeDetailScreen( ) ) - // UnitType Dropdown - ExposedDropdownMenuBox( - expanded = expandedUnit, - onExpandedChange = { expandedUnit = !expandedUnit } - ) { - OutlinedTextField( - 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 } + if (unitDropdownEnabled) { + // UnitType Dropdown + ExposedDropdownMenuBox( + expanded = expandedUnit && unitDropdownEnabled, + onExpandedChange = { + if (unitDropdownEnabled) expandedUnit = !expandedUnit + } ) { - UnitType.entries.forEach { unit -> - DropdownMenuItem( - text = { Text(unit.name.lowercase().replaceFirstChar { it.uppercase() }) }, - onClick = { - selectedUnit = unit - expandedUnit = false + OutlinedTextField( + readOnly = true, + value = selectedUnit.displayName.lowercase() + .replaceFirstChar { it.uppercase() }, + onValueChange = {}, + 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( readOnly = true, - value = selectedInputType.name.lowercase().replaceFirstChar { it.uppercase() }, // Format for display + value = selectedInputType.name.lowercase().replaceFirstChar { it.uppercase() }, onValueChange = {}, label = { Text(stringResource(R.string.measurement_type_label_input_type)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedInputType) - }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedInputType) }, modifier = Modifier - .menuAnchor( // Required for ExposedDropdownMenu - type = MenuAnchorType.PrimaryNotEditable, - enabled = true - ) + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true) .fillMaxWidth() ) ExposedDropdownMenu( @@ -371,73 +421,52 @@ fun MeasurementTypeDetailScreen( } } - // Color Picker Dialog if (showColorPicker) { ColorPickerDialog( currentColor = Color(selectedColor), - onColorSelected = { - selectedColor = it.toArgb() - // showColorPicker = false // Keep picker open until explicitly dismissed by user - }, + onColorSelected = { selectedColor = it.toArgb() }, onDismiss = { showColorPicker = false } ) } - // Icon Picker Dialog if (showIconPicker) { IconPickerDialog( onIconSelected = { selectedIcon = it - showIconPicker = false // Close picker after selection + 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 private fun OutlinedSettingRow( label: String, modifier: Modifier = Modifier, controlContent: @Composable () -> Unit ) { - Surface( // Surface for the border and background, mimicking OutlinedTextField + Surface( modifier = modifier .fillMaxWidth() - .heightIn(min = OutlinedTextFieldDefaults.MinHeight), // Minimum height similar to OutlinedTextField - shape = OutlinedTextFieldDefaults.shape, // Shape similar to OutlinedTextField - color = MaterialTheme.colorScheme.surface, // Background color (can be customized) - border = BorderStroke( // Border - width = 1.dp, // OutlinedTextFieldDefaults.UnfocusedBorderThickness is internal, so using 1.dp - color = MaterialTheme.colorScheme.outline // Border color similar to OutlinedTextField - ) + .heightIn(min = OutlinedTextFieldDefaults.MinHeight), + shape = OutlinedTextFieldDefaults.shape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline) ) { Row( modifier = Modifier .fillMaxWidth() - .padding( // Internal padding similar to OutlinedTextField - 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 - ), + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween // Pushes label to start, control to end + horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = label, - style = MaterialTheme.typography.bodyLarge, // Style for the "label" - color = MaterialTheme.colorScheme.onSurfaceVariant // Color of the "label" + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - controlContent() // The Switch or other control is placed here + controlContent() } } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt index 2c49b8b6..524bb5fb 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt @@ -18,25 +18,34 @@ package com.health.openscale.ui.screen.settings import android.widget.Toast +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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.DatePickerDialog import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox 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.OutlinedTextField import androidx.compose.material3.RadioButton @@ -53,16 +62,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import com.health.openscale.R import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType 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.WeightUnit +import com.health.openscale.core.utils.Converters import com.health.openscale.ui.screen.SharedViewModel import kotlinx.coroutines.launch import java.text.DateFormat @@ -111,10 +124,11 @@ fun UserDetailScreen( } } 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 scaleUnit by remember { mutableStateOf(user?.scaleUnit ?: WeightUnit.KG) } - var measureUnit by remember { mutableStateOf(user?.measureUnit ?: MeasureUnit.CM) } val context = LocalContext.current val dateFormatter = remember { @@ -125,8 +139,6 @@ fun UserDetailScreen( val datePickerState = rememberDatePickerState(initialSelectedDateMillis = birthDate) var showDatePicker by remember { mutableStateOf(false) } var activityLevelExpanded by remember { mutableStateOf(false) } - var scaleUnitExpanded by remember { mutableStateOf(false) } - var measureUnitExpanded by remember { mutableStateOf(false) } if (showDatePicker) { DatePickerDialog( @@ -154,6 +166,24 @@ fun UserDetailScreen( val editUserTitle = stringResource(R.string.user_detail_edit_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. // This runs when userId changes or the screen is first composed. LaunchedEffect(userId) { @@ -163,17 +193,25 @@ fun UserDetailScreen( ) sharedViewModel.setTopBarAction( SharedViewModel.TopBarAction(icon = Icons.Default.Save, onClick = { - val validHeight = height.toFloatOrNull() - if (name.isNotBlank() && validHeight != null) { + val validNumericHeight = heightValueString.toFloatOrNull() + 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( id = user?.id ?: 0, // Use existing ID if editing, or 0 for Room to auto-generate name = name, birthDate = birthDate, gender = gender, - heightCm = validHeight, - activityLevel = activityLevel, - scaleUnit = scaleUnit, - measureUnit = measureUnit + heightCm = finalHeightCm, + activityLevel = activityLevel ) settingsViewModel.viewModelScope.launch { if (isEdit) { @@ -203,7 +241,7 @@ fun UserDetailScreen( modifier = Modifier .padding(16.dp) .fillMaxSize() - .verticalScroll(scrollState), // Make the column scrollable + .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedTextField( @@ -213,14 +251,59 @@ fun UserDetailScreen( modifier = Modifier.fillMaxWidth() ) + Text(stringResource(id = R.string.user_detail_label_height)) OutlinedTextField( - value = height, - onValueChange = { height = it }, - label = { Text(stringResource(id = R.string.user_detail_label_height_cm)) }, // "Height (cm)" + value = heightValueString, + onValueChange = { newValue -> + 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), - 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" Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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" OutlinedTextField( value = dateFormatter.format(Date(birthDate)), 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 28d71569..c43299d4 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -48,18 +48,17 @@ Benutzer %1$s erfolgreich aktualisiert. Fehler beim Aktualisieren von Benutzer %1$s. Name - Größe (cm) + Größe Geschlecht Aktivitätslevel Geburtsdatum Bitte geben Sie gültige Daten ein - Gewichtseinheit - Größeneinheit %.1f cm Alter: %1$d, Größe: %2$s Benutzer bearbeiten Benutzer löschen Neuen Benutzer hinzufügen + Einheit ändern Messung erfolgreich aktualisiert diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 2926e415..ed6a4d55 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -49,18 +49,17 @@ User %1$s updated successfully. Error updating user %1$s. Name - Height (cm) + Height Gender Activity Level Birth Date Please enter valid data - Scale Unit - Measure Unit %.1f cm Age: %1$d, Height: %2$s Edit user Delete user Add new user + Change unit Measurement successfully updated