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 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-13 13:46:55 +01:00
parent 413dcce433
commit af33595f43
6 changed files with 167 additions and 7 deletions

View File

@@ -304,7 +304,13 @@ private fun MainScaffold(
.padding(contentPadding) .padding(contentPadding)
) { ) {
composable(AppRoutes.CHARACTER_SHEET) { composable(AppRoutes.CHARACTER_SHEET) {
CharacterSheetPage(character, contentPadding) CharacterSheetPage(
character = character,
contentPadding = contentPadding,
onUpdateCharacter = { transform ->
characterViewModel.updateCharacter(transform)
}
)
} }
composable(AppRoutes.SETTINGS) { composable(AppRoutes.SETTINGS) {
SettingsPage() SettingsPage()

View File

@@ -30,6 +30,13 @@ object TestTags {
// --- Dice / roll buttons --- // --- Dice / roll buttons ---
fun rollButton(name: String): String = "roll_button_${name.lowercase().replace(" ", "_")}" 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 --- // --- Dice roll result dialog ---
const val DICE_ROLL_DIALOG = "dice_roll_dialog" const val DICE_ROLL_DIALOG = "dice_roll_dialog"
const val DICE_ROLL_DICE_COUNT = "dice_roll_dice_count" const val DICE_ROLL_DICE_COUNT = "dice_roll_dice_count"

View File

@@ -10,8 +10,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import org.shahondin1624.lib.components.UiConstants import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.lib.components.charactermodel.attributespage.Attribute 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.components.charactermodel.attributespage.Talent
import org.shahondin1624.lib.functions.DiceRoll import org.shahondin1624.lib.functions.DiceRoll
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.charactermodel.ShadowrunCharacter import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.theme.LocalWindowSizeClass import org.shahondin1624.theme.LocalWindowSizeClass
import org.shahondin1624.theme.WindowSizeClass 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. * Replaces the previous single-scroll layout with fast tab switching.
*/ */
@Composable @Composable
fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) { fun CharacterSheetPage(
character: ShadowrunCharacter,
contentPadding: Dp,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {}
) {
val windowSizeClass = LocalWindowSizeClass.current val windowSizeClass = LocalWindowSizeClass.current
var selectedTab by remember { mutableStateOf(CharacterTab.Overview) } var selectedTab by remember { mutableStateOf(CharacterTab.Overview) }
val spacing = UiConstants.Spacing.medium(windowSizeClass) val spacing = UiConstants.Spacing.medium(windowSizeClass)
@@ -45,6 +51,9 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) {
pendingRollLabel = label pendingRollLabel = label
} }
// Attribute edit dialog state
var editingAttributeType by remember { mutableStateOf<AttributeType?>(null) }
// Show dice roll result dialog // Show dice roll result dialog
pendingRoll?.let { roll -> pendingRoll?.let { roll ->
DiceRollResultDialog( 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()) { Column(modifier = Modifier.fillMaxSize()) {
// Tab row // Tab row
TabRow( TabRow(
@@ -69,12 +94,16 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) {
} }
// Tab content // Tab content
val onEditAttribute: (AttributeType) -> Unit = { type ->
editingAttributeType = type
}
when (windowSizeClass) { when (windowSizeClass) {
WindowSizeClass.Expanded -> { WindowSizeClass.Expanded -> {
// Expanded: two-column layout for Overview and Combat // Expanded: two-column layout for Overview and Combat
when (selectedTab) { when (selectedTab) {
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing) 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.Talents -> TalentsContent(character, spacing, onDiceRoll)
CharacterTab.Combat -> CombatContent(character, spacing) CharacterTab.Combat -> CombatContent(character, spacing)
} }
@@ -82,7 +111,7 @@ fun CharacterSheetPage(character: ShadowrunCharacter, contentPadding: Dp) {
else -> { else -> {
when (selectedTab) { when (selectedTab) {
CharacterTab.Overview -> OverviewContent(character, spacing) 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.Talents -> TalentsContent(character, spacing, onDiceRoll)
CharacterTab.Combat -> CombatContent(character, spacing) CharacterTab.Combat -> CombatContent(character, spacing)
} }
@@ -134,7 +163,8 @@ private fun ExpandedOverviewContent(character: ShadowrunCharacter, spacing: Dp)
private fun AttributesContent( private fun AttributesContent(
character: ShadowrunCharacter, character: ShadowrunCharacter,
spacing: Dp, spacing: Dp,
onDiceRoll: (DiceRoll, String) -> Unit onDiceRoll: (DiceRoll, String) -> Unit,
onEditAttribute: (AttributeType) -> Unit
) { ) {
val windowSizeClass = LocalWindowSizeClass.current val windowSizeClass = LocalWindowSizeClass.current
val columns = UiConstants.Grid.totalColumns(windowSizeClass) val columns = UiConstants.Grid.totalColumns(windowSizeClass)
@@ -157,7 +187,8 @@ private fun AttributesContent(
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
Attribute( Attribute(
attribute = attr, attribute = attr,
onRoll = { roll -> onDiceRoll(roll, attr.type.name) } onRoll = { roll -> onDiceRoll(roll, attr.type.name) },
onEdit = { onEditAttribute(attr.type) }
) )
} }
} }

View File

@@ -2,6 +2,7 @@ package org.shahondin1624.lib.components.charactermodel.attributespage
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -35,10 +36,18 @@ fun Attribute(
onRoll: (DiceRoll) -> Unit = { onRoll: (DiceRoll) -> Unit = {
println("Result for $attribute: ${it.result}") println("Result for $attribute: ${it.result}")
}, },
onEdit: (() -> Unit)? = null,
) { ) {
var isInDarkMode by LocalThemeIsDark.current var isInDarkMode by LocalThemeIsDark.current
val textColor = if (isInDarkMode) Color.Black else Color.White 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,

View File

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

View File

@@ -121,4 +121,21 @@ data class Attributes(
fun getAllAttributes(): List<Attribute> { fun getAllAttributes(): List<Attribute> {
return listOf(body, agility, reaction, strength, willpower, logic, intuition, charisma) 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)
}
}
} }