1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-16 05:34:05 +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,
"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')"
]
}
}

View File

@@ -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<UnitType>
) {
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());
}

View File

@@ -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
)

View File

@@ -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()
}
}
}

View File

@@ -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)),

View File

@@ -48,18 +48,17 @@
<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_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_activity_level">Aktivitätslevel</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_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="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_delete">Benutzer löschen</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 -->
<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_error">Error updating user %1$s.</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_activity_level">Activity Level</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_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="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_delete">Delete 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 -->
<string name="success_measurement_updated">Measurement successfully updated</string>