From c547de9297197b30f924967ca6c12e1fd4f7eaac Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 13:54:00 +0100 Subject: [PATCH] feat: add editable character data fields (Closes #26) Add CharacterDataEditDialog with text fields (name, concept, gender), metatype dropdown (Human/Elf/Dwarf/Ork/Troll), number inputs (age, nuyen, karma total/current), and decimal input for essence (0.0-6.0). CharacterHeader card opens edit dialog on tap. All changes propagate through CharacterViewModel immediately. Co-Authored-By: Claude Opus 4.6 --- .../shahondin1624/lib/components/TestTags.kt | 14 + .../charactermodel/CharacterDataEditDialog.kt | 240 ++++++++++++++++++ .../charactermodel/CharacterHeader.kt | 10 +- .../charactermodel/CharacterSheetPage.kt | 40 ++- 4 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterDataEditDialog.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index ad763c5..334fb5a 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -37,6 +37,20 @@ object TestTags { const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss" const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error" + // --- Character data edit dialog --- + const val CHARACTER_DATA_EDIT_DIALOG = "character_data_edit_dialog" + const val CHARACTER_DATA_EDIT_NAME = "character_data_edit_name" + const val CHARACTER_DATA_EDIT_CONCEPT = "character_data_edit_concept" + const val CHARACTER_DATA_EDIT_GENDER = "character_data_edit_gender" + const val CHARACTER_DATA_EDIT_METATYPE = "character_data_edit_metatype" + const val CHARACTER_DATA_EDIT_AGE = "character_data_edit_age" + const val CHARACTER_DATA_EDIT_NUYEN = "character_data_edit_nuyen" + const val CHARACTER_DATA_EDIT_ESSENCE = "character_data_edit_essence" + const val CHARACTER_DATA_EDIT_TOTAL_KARMA = "character_data_edit_total_karma" + const val CHARACTER_DATA_EDIT_CURRENT_KARMA = "character_data_edit_current_karma" + const val CHARACTER_DATA_EDIT_CONFIRM = "character_data_edit_confirm" + const val CHARACTER_DATA_EDIT_DISMISS = "character_data_edit_dismiss" + // --- Talent edit dialog --- const val TALENT_EDIT_DIALOG = "talent_edit_dialog" const val TALENT_EDIT_INPUT = "talent_edit_input" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterDataEditDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterDataEditDialog.kt new file mode 100644 index 0000000..179877f --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterDataEditDialog.kt @@ -0,0 +1,240 @@ +package org.shahondin1624.lib.components.charactermodel + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.model.characterdata.CharacterData +import org.shahondin1624.model.characterdata.Metatype + +/** + * Dialog for editing character data fields: name, concept, metatype, + * age, gender, nuyen, essence, karma (total and current). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CharacterDataEditDialog( + characterData: CharacterData, + onConfirm: (CharacterData) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(characterData.name) } + var concept by remember { mutableStateOf(characterData.concept) } + var gender by remember { mutableStateOf(characterData.gender) } + var metatype by remember { mutableStateOf(characterData.metatype) } + var ageText by remember { mutableStateOf(characterData.age.toString()) } + var nuyenText by remember { mutableStateOf(characterData.nuyen.toString()) } + var essenceText by remember { mutableStateOf(formatEssenceForEdit(characterData.essence)) } + var totalKarmaText by remember { mutableStateOf(characterData.totalKarma.toString()) } + var currentKarmaText by remember { mutableStateOf(characterData.currentKarma.toString()) } + + var metatypeExpanded by remember { mutableStateOf(false) } + + val age = ageText.toIntOrNull() + val nuyen = nuyenText.toIntOrNull() + val essence = essenceText.toFloatOrNull() + val totalKarma = totalKarmaText.toIntOrNull() + val currentKarma = currentKarmaText.toIntOrNull() + + val isValid = name.isNotBlank() && + age != null && age >= 0 && + nuyen != null && nuyen >= 0 && + essence != null && essence in 0.0f..6.0f && + totalKarma != null && totalKarma >= 0 && + currentKarma != null && currentKarma >= 0 && + currentKarma <= totalKarma + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_DIALOG), + title = { Text("Edit Character") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_NAME) + ) + + // Concept + OutlinedTextField( + value = concept, + onValueChange = { concept = it }, + label = { Text("Concept") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_CONCEPT) + ) + + // Gender + OutlinedTextField( + value = gender, + onValueChange = { gender = it }, + label = { Text("Gender") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_GENDER) + ) + + // Metatype dropdown + ExposedDropdownMenuBox( + expanded = metatypeExpanded, + onExpandedChange = { metatypeExpanded = it }, + modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_METATYPE) + ) { + OutlinedTextField( + value = metatype.name, + onValueChange = {}, + readOnly = true, + label = { Text("Metatype") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = metatypeExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = metatypeExpanded, + onDismissRequest = { metatypeExpanded = false } + ) { + Metatype.entries.forEach { mt -> + DropdownMenuItem( + text = { Text(mt.name) }, + onClick = { + metatype = mt + metatypeExpanded = false + } + ) + } + } + } + + // Age + OutlinedTextField( + value = ageText, + onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) ageText = it }, + label = { Text("Age") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = age == null && ageText.isNotEmpty(), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_AGE) + ) + + // Nuyen + OutlinedTextField( + value = nuyenText, + onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) nuyenText = it }, + label = { Text("Nuyen") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = nuyen == null && nuyenText.isNotEmpty(), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_NUYEN) + ) + + // Essence + OutlinedTextField( + value = essenceText, + onValueChange = { newVal -> + if (newVal.isEmpty() || newVal.matches(Regex("^\\d*\\.?\\d*$"))) { + essenceText = newVal + } + }, + label = { Text("Essence (0.0 - 6.0)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + isError = (essence == null || essence !in 0.0f..6.0f) && essenceText.isNotEmpty(), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_ESSENCE) + ) + + // Total Karma + OutlinedTextField( + value = totalKarmaText, + onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) totalKarmaText = it }, + label = { Text("Total Karma") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_TOTAL_KARMA) + ) + + // Current Karma + OutlinedTextField( + value = currentKarmaText, + onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) currentKarmaText = it }, + label = { Text("Current Karma") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = currentKarma != null && totalKarma != null && currentKarma > totalKarma, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.CHARACTER_DATA_EDIT_CURRENT_KARMA) + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (isValid) { + onConfirm( + characterData.copy( + name = name, + concept = concept, + gender = gender, + metatype = metatype, + age = age!!, + nuyen = nuyen!!, + essence = essence!!, + totalKarma = totalKarma!!, + currentKarma = currentKarma!! + ) + ) + } + }, + enabled = isValid, + modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM) + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_DISMISS) + ) { + Text("Cancel") + } + } + ) +} + +/** + * Format essence float for editing, showing one decimal place. + */ +private fun formatEssenceForEdit(essence: Float): String { + val wholePart = essence.toInt() + val decimalPart = ((essence - wholePart) * 10 + 0.5f).toInt() + return "$wholePart.$decimalPart" +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterHeader.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterHeader.kt index 6e489a0..c4a826a 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterHeader.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterHeader.kt @@ -1,5 +1,6 @@ package org.shahondin1624.lib.components.charactermodel +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -19,7 +20,10 @@ import org.shahondin1624.theme.WindowSizeClass * Stacks vertically on Compact, horizontal row on Expanded. */ @Composable -fun CharacterHeader(characterData: CharacterData) { +fun CharacterHeader( + characterData: CharacterData, + onEdit: (() -> Unit)? = null +) { val windowSizeClass = LocalWindowSizeClass.current val spacing = UiConstants.Spacing.medium(windowSizeClass) @@ -27,6 +31,10 @@ fun CharacterHeader(characterData: CharacterData) { modifier = Modifier .fillMaxWidth() .testTag(TestTags.PANEL_CHARACTER_HEADER) + .then( + if (onEdit != null) Modifier.clickable { onEdit() } + else Modifier + ) ) { when (windowSizeClass) { WindowSizeClass.Compact -> CompactHeader(characterData, spacing) diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index 610a195..a24f97d 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -59,6 +59,9 @@ fun CharacterSheetPage( // Talent edit dialog state var editingTalent by remember { mutableStateOf(null) } + // Character data edit dialog state + var editingCharacterData by remember { mutableStateOf(false) } + // Show dice roll result dialog pendingRoll?.let { roll -> DiceRollResultDialog( @@ -99,6 +102,20 @@ fun CharacterSheetPage( ) } + // Show character data edit dialog + if (editingCharacterData) { + CharacterDataEditDialog( + characterData = character.characterData, + onConfirm = { newData -> + onUpdateCharacter { char -> + char.copy(characterData = newData) + } + editingCharacterData = false + }, + onDismiss = { editingCharacterData = false } + ) + } + Column(modifier = Modifier.fillMaxSize()) { // Tab row TabRow( @@ -120,12 +137,15 @@ fun CharacterSheetPage( val onEditTalent: (TalentDefinition) -> Unit = { talent -> editingTalent = talent } + val onEditCharacterData: () -> Unit = { + editingCharacterData = true + } when (windowSizeClass) { WindowSizeClass.Expanded -> { // Expanded: two-column layout for Overview and Combat when (selectedTab) { - CharacterTab.Overview -> ExpandedOverviewContent(character, spacing) + CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) CharacterTab.Combat -> CombatContent(character, spacing) @@ -133,7 +153,7 @@ fun CharacterSheetPage( } else -> { when (selectedTab) { - CharacterTab.Overview -> OverviewContent(character, spacing) + CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) CharacterTab.Combat -> CombatContent(character, spacing) @@ -144,7 +164,11 @@ fun CharacterSheetPage( } @Composable -private fun OverviewContent(character: ShadowrunCharacter, spacing: Dp) { +private fun OverviewContent( + character: ShadowrunCharacter, + spacing: Dp, + onEditCharacterData: () -> Unit +) { Column( modifier = Modifier .fillMaxSize() @@ -152,14 +176,18 @@ private fun OverviewContent(character: ShadowrunCharacter, spacing: Dp) { .padding(spacing), verticalArrangement = Arrangement.spacedBy(spacing) ) { - CharacterHeader(character.characterData) + CharacterHeader(character.characterData, onEdit = onEditCharacterData) ResourcePanel(character.characterData, character.attributes.edge) DerivedAttributesPanel(character.attributes) } } @Composable -private fun ExpandedOverviewContent(character: ShadowrunCharacter, spacing: Dp) { +private fun ExpandedOverviewContent( + character: ShadowrunCharacter, + spacing: Dp, + onEditCharacterData: () -> Unit +) { Column( modifier = Modifier .fillMaxSize() @@ -167,7 +195,7 @@ private fun ExpandedOverviewContent(character: ShadowrunCharacter, spacing: Dp) .padding(spacing), verticalArrangement = Arrangement.spacedBy(spacing) ) { - CharacterHeader(character.characterData) + CharacterHeader(character.characterData, onEdit = onEditCharacterData) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(spacing)