feat: add editable talent ratings with validation dialog (Closes #25)

Add TalentEditDialog with rating input (0-12, 0 = untrained) and
validation. Talent cards now open edit dialog on tap. Dice pool
display updates immediately through CharacterViewModel. Add
withTalentRating() to Talents model for immutable talent rating updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-13 13:50:18 +01:00
parent 111c86c469
commit d12269cdb5
5 changed files with 155 additions and 6 deletions

View File

@@ -37,6 +37,13 @@ object TestTags {
const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss" const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss"
const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error" const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error"
// --- Talent edit dialog ---
const val TALENT_EDIT_DIALOG = "talent_edit_dialog"
const val TALENT_EDIT_INPUT = "talent_edit_input"
const val TALENT_EDIT_CONFIRM = "talent_edit_confirm"
const val TALENT_EDIT_DISMISS = "talent_edit_dismiss"
const val TALENT_EDIT_ERROR = "talent_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

@@ -12,6 +12,8 @@ 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.AttributeEditDialog
import org.shahondin1624.lib.components.charactermodel.attributespage.Talent import org.shahondin1624.lib.components.charactermodel.attributespage.Talent
import org.shahondin1624.lib.components.charactermodel.attributespage.TalentEditDialog
import org.shahondin1624.model.talents.TalentDefinition
import org.shahondin1624.lib.functions.DiceRoll import org.shahondin1624.lib.functions.DiceRoll
import org.shahondin1624.model.attributes.AttributeType import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.charactermodel.ShadowrunCharacter import org.shahondin1624.model.charactermodel.ShadowrunCharacter
@@ -54,6 +56,9 @@ fun CharacterSheetPage(
// Attribute edit dialog state // Attribute edit dialog state
var editingAttributeType by remember { mutableStateOf<AttributeType?>(null) } var editingAttributeType by remember { mutableStateOf<AttributeType?>(null) }
// Talent edit dialog state
var editingTalent by remember { mutableStateOf<TalentDefinition?>(null) }
// Show dice roll result dialog // Show dice roll result dialog
pendingRoll?.let { roll -> pendingRoll?.let { roll ->
DiceRollResultDialog( DiceRollResultDialog(
@@ -79,6 +84,21 @@ fun CharacterSheetPage(
) )
} }
// Show talent edit dialog
editingTalent?.let { talent ->
TalentEditDialog(
talent = talent,
onConfirm = { newValue ->
onUpdateCharacter { char ->
val newTalents = char.talents.withTalentRating(talent.name, newValue)
char.copy(talents = newTalents)
}
editingTalent = null
},
onDismiss = { editingTalent = null }
)
}
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Tab row // Tab row
TabRow( TabRow(
@@ -97,6 +117,9 @@ fun CharacterSheetPage(
val onEditAttribute: (AttributeType) -> Unit = { type -> val onEditAttribute: (AttributeType) -> Unit = { type ->
editingAttributeType = type editingAttributeType = type
} }
val onEditTalent: (TalentDefinition) -> Unit = { talent ->
editingTalent = talent
}
when (windowSizeClass) { when (windowSizeClass) {
WindowSizeClass.Expanded -> { WindowSizeClass.Expanded -> {
@@ -104,7 +127,7 @@ fun CharacterSheetPage(
when (selectedTab) { when (selectedTab) {
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing) CharacterTab.Overview -> ExpandedOverviewContent(character, spacing)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing) CharacterTab.Combat -> CombatContent(character, spacing)
} }
} }
@@ -112,7 +135,7 @@ fun CharacterSheetPage(
when (selectedTab) { when (selectedTab) {
CharacterTab.Overview -> OverviewContent(character, spacing) CharacterTab.Overview -> OverviewContent(character, spacing)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing) CharacterTab.Combat -> CombatContent(character, spacing)
} }
} }
@@ -204,7 +227,8 @@ private fun AttributesContent(
private fun TalentsContent( private fun TalentsContent(
character: ShadowrunCharacter, character: ShadowrunCharacter,
spacing: Dp, spacing: Dp,
onDiceRoll: (DiceRoll, String) -> Unit onDiceRoll: (DiceRoll, String) -> Unit,
onEditTalent: (TalentDefinition) -> Unit
) { ) {
val windowSizeClass = LocalWindowSizeClass.current val windowSizeClass = LocalWindowSizeClass.current
val totalCols = UiConstants.Grid.totalColumns(windowSizeClass) val totalCols = UiConstants.Grid.totalColumns(windowSizeClass)
@@ -230,7 +254,8 @@ private fun TalentsContent(
Talent( Talent(
talent = talent, talent = talent,
attributes = character.attributes, attributes = character.attributes,
onRoll = { roll -> onDiceRoll(roll, talent.name) } onRoll = { roll -> onDiceRoll(roll, talent.name) },
onEdit = { onEditTalent(talent) }
) )
} }
} }

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.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -42,10 +43,18 @@ fun Talent(
onRoll: (DiceRoll) -> Unit = { onRoll: (DiceRoll) -> Unit = {
println("Result for $talent: ${it.result}") println("Result for $talent: ${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.talentCard(talent.name))) { Card(
modifier = Modifier
.testTag(TestTags.talentCard(talent.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,88 @@
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.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.talents.TalentDefinition
/**
* Dialog for editing a talent's rating.
* Rating range is 0-12; 0 means untrained.
*/
@Composable
fun TalentEditDialog(
talent: TalentDefinition,
maxValue: Int = 12,
onConfirm: (Int) -> Unit,
onDismiss: () -> Unit
) {
var textValue by remember { mutableStateOf(talent.value.toString()) }
val parsedValue = textValue.toIntOrNull()
val isValid = parsedValue != null && parsedValue in 0..maxValue
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.TALENT_EDIT_DIALOG),
title = {
Text(text = "Edit ${talent.name}")
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = textValue,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() } || newValue.isEmpty()) {
textValue = newValue
}
},
label = { Text("Rating (0-$maxValue, 0 = untrained)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = !isValid && textValue.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.TALENT_EDIT_INPUT)
)
if (!isValid && textValue.isNotEmpty()) {
Text(
text = if (parsedValue != null && parsedValue < 0) {
"Minimum rating is 0"
} else if (parsedValue != null && parsedValue > maxValue) {
"Maximum rating is $maxValue"
} else {
"Enter a valid number"
},
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.TALENT_EDIT_ERROR)
)
}
}
},
confirmButton = {
TextButton(
onClick = { if (isValid) onConfirm(parsedValue!!) },
enabled = isValid,
modifier = Modifier.testTag(TestTags.TALENT_EDIT_CONFIRM)
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.TALENT_EDIT_DISMISS)
) {
Text("Cancel")
}
}
)
}

View File

@@ -7,4 +7,24 @@ import org.shahondin1624.model.Versionable
data class Talents( data class Talents(
val talents: List<TalentDefinition> = createAllProvidedTalents(), val talents: List<TalentDefinition> = createAllProvidedTalents(),
override val version: String = "v0.1" override val version: String = "v0.1"
) : Versionable ) : Versionable {
/**
* Returns a copy with the specified talent's value updated.
*/
fun withTalentRating(talentName: String, newValue: Int): Talents {
return copy(
talents = talents.map { talent ->
if (talent.name == talentName) {
Talent(
name = talent.name,
attribute = talent.attribute,
value = newValue,
custom = talent.custom
)
} else {
talent
}
}
)
}
}