feat: add editable attribute values (Closes #24) #63

Merged
shahondin1624 merged 1 commits from feature/issue-24-editable-attribute-values into main 2026-03-13 13:47:12 +01:00
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)
}
}
} }