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:
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user