From af33595f431ad11d98a196a8c0c01aa306f5d92c Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 13:46:55 +0100 Subject: [PATCH] feat: add editable attribute values with validation dialog (Closes #24) Add AttributeEditDialog with number input (min 1, max 10), validation error messages, and save/cancel buttons. Attribute cards now open the edit dialog on tap. Changes propagate through CharacterViewModel so derived attributes update immediately. Add withAttribute() to Attributes model for immutable attribute updates by type. Co-Authored-By: Claude Opus 4.6 --- .../kotlin/org/shahondin1624/App.kt | 8 +- .../shahondin1624/lib/components/TestTags.kt | 7 ++ .../charactermodel/CharacterSheetPage.kt | 41 +++++++-- .../attributespage/Attribute.kt | 11 ++- .../attributespage/AttributeEditDialog.kt | 90 +++++++++++++++++++ .../model/attributes/Attributes.kt | 17 ++++ 6 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/AttributeEditDialog.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 9760b1c..4852c53 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -304,7 +304,13 @@ private fun MainScaffold( .padding(contentPadding) ) { composable(AppRoutes.CHARACTER_SHEET) { - CharacterSheetPage(character, contentPadding) + CharacterSheetPage( + character = character, + contentPadding = contentPadding, + onUpdateCharacter = { transform -> + characterViewModel.updateCharacter(transform) + } + ) } composable(AppRoutes.SETTINGS) { SettingsPage() 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 9b3162b..b95b172 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -30,6 +30,13 @@ object TestTags { // --- Dice / roll buttons --- fun rollButton(name: String): String = "roll_button_${name.lowercase().replace(" ", "_")}" + // --- Attribute edit dialog --- + const val ATTRIBUTE_EDIT_DIALOG = "attribute_edit_dialog" + const val ATTRIBUTE_EDIT_INPUT = "attribute_edit_input" + const val ATTRIBUTE_EDIT_CONFIRM = "attribute_edit_confirm" + const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss" + const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error" + // --- Dice roll result dialog --- const val DICE_ROLL_DIALOG = "dice_roll_dialog" const val DICE_ROLL_DICE_COUNT = "dice_roll_dice_count" 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 f409ae2..9ded6bd 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 @@ -10,8 +10,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import org.shahondin1624.lib.components.UiConstants import org.shahondin1624.lib.components.charactermodel.attributespage.Attribute +import org.shahondin1624.lib.components.charactermodel.attributespage.AttributeEditDialog import org.shahondin1624.lib.components.charactermodel.attributespage.Talent import org.shahondin1624.lib.functions.DiceRoll +import org.shahondin1624.model.attributes.AttributeType import org.shahondin1624.model.charactermodel.ShadowrunCharacter import org.shahondin1624.theme.LocalWindowSizeClass import org.shahondin1624.theme.WindowSizeClass @@ -31,7 +33,11 @@ private enum class CharacterTab(val title: String) { * Replaces the previous single-scroll layout with fast tab switching. */ @Composable -fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) { +fun CharacterSheetPage( + character: ShadowrunCharacter, + contentPadding: Dp, + onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {} +) { val windowSizeClass = LocalWindowSizeClass.current var selectedTab by remember { mutableStateOf(CharacterTab.Overview) } val spacing = UiConstants.Spacing.medium(windowSizeClass) @@ -45,6 +51,9 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) { pendingRollLabel = label } + // Attribute edit dialog state + var editingAttributeType by remember { mutableStateOf(null) } + // Show dice roll result dialog pendingRoll?.let { roll -> DiceRollResultDialog( @@ -54,6 +63,22 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) { ) } + // Show attribute edit dialog + editingAttributeType?.let { attrType -> + val attr = character.attributes.getAttributeByType(attrType) + AttributeEditDialog( + attribute = attr, + onConfirm = { newValue -> + onUpdateCharacter { char -> + val newAttributes = char.attributes.withAttribute(attrType, newValue) + char.copy(attributes = newAttributes) + } + editingAttributeType = null + }, + onDismiss = { editingAttributeType = null } + ) + } + Column(modifier = Modifier.fillMaxSize()) { // Tab row TabRow( @@ -69,12 +94,16 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) { } // Tab content + val onEditAttribute: (AttributeType) -> Unit = { type -> + editingAttributeType = type + } + when (windowSizeClass) { WindowSizeClass.Expanded -> { // Expanded: two-column layout for Overview and Combat when (selectedTab) { CharacterTab.Overview -> ExpandedOverviewContent(character, spacing) - CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll) + CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll) CharacterTab.Combat -> CombatContent(character, spacing) } @@ -82,7 +111,7 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) { else -> { when (selectedTab) { CharacterTab.Overview -> OverviewContent(character, spacing) - CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll) + CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll) CharacterTab.Combat -> CombatContent(character, spacing) } @@ -134,7 +163,8 @@ private fun ExpandedOverviewContent(character: ShadowrunCharacter, spacing: Dp) private fun AttributesContent( character: ShadowrunCharacter, spacing: Dp, - onDiceRoll: (DiceRoll, String) -> Unit + onDiceRoll: (DiceRoll, String) -> Unit, + onEditAttribute: (AttributeType) -> Unit ) { val windowSizeClass = LocalWindowSizeClass.current val columns = UiConstants.Grid.totalColumns(windowSizeClass) @@ -157,7 +187,8 @@ private fun AttributesContent( Box(modifier = Modifier.weight(1f)) { Attribute( attribute = attr, - onRoll = { roll -> onDiceRoll(roll, attr.type.name) } + onRoll = { roll -> onDiceRoll(roll, attr.type.name) }, + onEdit = { onEditAttribute(attr.type) } ) } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt index c97339a..c28acc9 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt @@ -2,6 +2,7 @@ package org.shahondin1624.lib.components.charactermodel.attributespage import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card @@ -35,10 +36,18 @@ fun Attribute( onRoll: (DiceRoll) -> Unit = { println("Result for $attribute: ${it.result}") }, + onEdit: (() -> Unit)? = null, ) { var isInDarkMode by LocalThemeIsDark.current val textColor = if (isInDarkMode) Color.Black else Color.White - Card(modifier = Modifier.testTag(TestTags.attributeCard(attribute.type.name))) { + Card( + modifier = Modifier + .testTag(TestTags.attributeCard(attribute.type.name)) + .then( + if (onEdit != null) Modifier.clickable { onEdit() } + else Modifier + ) + ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/AttributeEditDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/AttributeEditDialog.kt new file mode 100644 index 0000000..1c5dc34 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/AttributeEditDialog.kt @@ -0,0 +1,90 @@ +package org.shahondin1624.lib.components.charactermodel.attributespage + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.attributes.Attribute + +/** + * Dialog for editing an attribute value. + * Provides a number input with validation for minimum 1 and configurable maximum. + */ +@Composable +fun AttributeEditDialog( + attribute: Attribute, + maxValue: Int = 10, + onConfirm: (Int) -> Unit, + onDismiss: () -> Unit +) { + var textValue by remember { mutableStateOf(attribute.value.toString()) } + val parsedValue = textValue.toIntOrNull() + val isValid = parsedValue != null && parsedValue in 1..maxValue + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.ATTRIBUTE_EDIT_DIALOG), + title = { + Text(text = "Edit ${attribute.type.name}") + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = textValue, + onValueChange = { newValue -> + // Allow only digits + if (newValue.all { it.isDigit() } || newValue.isEmpty()) { + textValue = newValue + } + }, + label = { Text("Value (1-$maxValue)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = !isValid && textValue.isNotEmpty(), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.ATTRIBUTE_EDIT_INPUT) + ) + if (!isValid && textValue.isNotEmpty()) { + Text( + text = if (parsedValue != null && parsedValue < 1) { + "Minimum value is 1" + } else if (parsedValue != null && parsedValue > maxValue) { + "Maximum value is $maxValue" + } else { + "Enter a valid number" + }, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag(TestTags.ATTRIBUTE_EDIT_ERROR) + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { if (isValid) onConfirm(parsedValue!!) }, + enabled = isValid, + modifier = Modifier.testTag(TestTags.ATTRIBUTE_EDIT_CONFIRM) + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.ATTRIBUTE_EDIT_DISMISS) + ) { + Text("Cancel") + } + } + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt index e300882..398efc4 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt @@ -121,4 +121,21 @@ data class Attributes( fun getAllAttributes(): List { return listOf(body, agility, reaction, strength, willpower, logic, intuition, charisma) } + + /** + * Returns a copy of this Attributes with the specified attribute type set to a new value. + */ + fun withAttribute(type: AttributeType, newValue: Int): Attributes { + val attr = Attribute(type, newValue) + return when (type) { + AttributeType.Body -> copy(body = attr) + AttributeType.Agility -> copy(agility = attr) + AttributeType.Reaction -> copy(reaction = attr) + AttributeType.Strength -> copy(strength = attr) + AttributeType.Willpower -> copy(willpower = attr) + AttributeType.Logic -> copy(logic = attr) + AttributeType.Intuition -> copy(intuition = attr) + AttributeType.Charisma -> copy(charisma = attr) + } + } } \ No newline at end of file