feat: add editable character data fields (Closes #26) (#65)

This commit was merged in pull request #65.
This commit is contained in:
2026-03-13 13:54:19 +01:00
parent 227b5c2864
commit 9e9a783e6a
4 changed files with 297 additions and 7 deletions

View File

@@ -37,6 +37,20 @@ object TestTags {
const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss"
const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error"
// --- Character data edit dialog ---
const val CHARACTER_DATA_EDIT_DIALOG = "character_data_edit_dialog"
const val CHARACTER_DATA_EDIT_NAME = "character_data_edit_name"
const val CHARACTER_DATA_EDIT_CONCEPT = "character_data_edit_concept"
const val CHARACTER_DATA_EDIT_GENDER = "character_data_edit_gender"
const val CHARACTER_DATA_EDIT_METATYPE = "character_data_edit_metatype"
const val CHARACTER_DATA_EDIT_AGE = "character_data_edit_age"
const val CHARACTER_DATA_EDIT_NUYEN = "character_data_edit_nuyen"
const val CHARACTER_DATA_EDIT_ESSENCE = "character_data_edit_essence"
const val CHARACTER_DATA_EDIT_TOTAL_KARMA = "character_data_edit_total_karma"
const val CHARACTER_DATA_EDIT_CURRENT_KARMA = "character_data_edit_current_karma"
const val CHARACTER_DATA_EDIT_CONFIRM = "character_data_edit_confirm"
const val CHARACTER_DATA_EDIT_DISMISS = "character_data_edit_dismiss"
// --- Talent edit dialog ---
const val TALENT_EDIT_DIALOG = "talent_edit_dialog"
const val TALENT_EDIT_INPUT = "talent_edit_input"

View File

@@ -0,0 +1,240 @@
package org.shahondin1624.lib.components.charactermodel
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Metatype
/**
* Dialog for editing character data fields: name, concept, metatype,
* age, gender, nuyen, essence, karma (total and current).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterDataEditDialog(
characterData: CharacterData,
onConfirm: (CharacterData) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(characterData.name) }
var concept by remember { mutableStateOf(characterData.concept) }
var gender by remember { mutableStateOf(characterData.gender) }
var metatype by remember { mutableStateOf(characterData.metatype) }
var ageText by remember { mutableStateOf(characterData.age.toString()) }
var nuyenText by remember { mutableStateOf(characterData.nuyen.toString()) }
var essenceText by remember { mutableStateOf(formatEssenceForEdit(characterData.essence)) }
var totalKarmaText by remember { mutableStateOf(characterData.totalKarma.toString()) }
var currentKarmaText by remember { mutableStateOf(characterData.currentKarma.toString()) }
var metatypeExpanded by remember { mutableStateOf(false) }
val age = ageText.toIntOrNull()
val nuyen = nuyenText.toIntOrNull()
val essence = essenceText.toFloatOrNull()
val totalKarma = totalKarmaText.toIntOrNull()
val currentKarma = currentKarmaText.toIntOrNull()
val isValid = name.isNotBlank() &&
age != null && age >= 0 &&
nuyen != null && nuyen >= 0 &&
essence != null && essence in 0.0f..6.0f &&
totalKarma != null && totalKarma >= 0 &&
currentKarma != null && currentKarma >= 0 &&
currentKarma <= totalKarma
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_DIALOG),
title = { Text("Edit Character") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_NAME)
)
// Concept
OutlinedTextField(
value = concept,
onValueChange = { concept = it },
label = { Text("Concept") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_CONCEPT)
)
// Gender
OutlinedTextField(
value = gender,
onValueChange = { gender = it },
label = { Text("Gender") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_GENDER)
)
// Metatype dropdown
ExposedDropdownMenuBox(
expanded = metatypeExpanded,
onExpandedChange = { metatypeExpanded = it },
modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_METATYPE)
) {
OutlinedTextField(
value = metatype.name,
onValueChange = {},
readOnly = true,
label = { Text("Metatype") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = metatypeExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = metatypeExpanded,
onDismissRequest = { metatypeExpanded = false }
) {
Metatype.entries.forEach { mt ->
DropdownMenuItem(
text = { Text(mt.name) },
onClick = {
metatype = mt
metatypeExpanded = false
}
)
}
}
}
// Age
OutlinedTextField(
value = ageText,
onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) ageText = it },
label = { Text("Age") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = age == null && ageText.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_AGE)
)
// Nuyen
OutlinedTextField(
value = nuyenText,
onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) nuyenText = it },
label = { Text("Nuyen") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = nuyen == null && nuyenText.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_NUYEN)
)
// Essence
OutlinedTextField(
value = essenceText,
onValueChange = { newVal ->
if (newVal.isEmpty() || newVal.matches(Regex("^\\d*\\.?\\d*$"))) {
essenceText = newVal
}
},
label = { Text("Essence (0.0 - 6.0)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = (essence == null || essence !in 0.0f..6.0f) && essenceText.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_ESSENCE)
)
// Total Karma
OutlinedTextField(
value = totalKarmaText,
onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) totalKarmaText = it },
label = { Text("Total Karma") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_TOTAL_KARMA)
)
// Current Karma
OutlinedTextField(
value = currentKarmaText,
onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) currentKarmaText = it },
label = { Text("Current Karma") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = currentKarma != null && totalKarma != null && currentKarma > totalKarma,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.CHARACTER_DATA_EDIT_CURRENT_KARMA)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
onConfirm(
characterData.copy(
name = name,
concept = concept,
gender = gender,
metatype = metatype,
age = age!!,
nuyen = nuyen!!,
essence = essence!!,
totalKarma = totalKarma!!,
currentKarma = currentKarma!!
)
)
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM)
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.CHARACTER_DATA_EDIT_DISMISS)
) {
Text("Cancel")
}
}
)
}
/**
* Format essence float for editing, showing one decimal place.
*/
private fun formatEssenceForEdit(essence: Float): String {
val wholePart = essence.toInt()
val decimalPart = ((essence - wholePart) * 10 + 0.5f).toInt()
return "$wholePart.$decimalPart"
}

View File

@@ -1,5 +1,6 @@
package org.shahondin1624.lib.components.charactermodel
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -19,7 +20,10 @@ import org.shahondin1624.theme.WindowSizeClass
* Stacks vertically on Compact, horizontal row on Expanded.
*/
@Composable
fun CharacterHeader(characterData: CharacterData) {
fun CharacterHeader(
characterData: CharacterData,
onEdit: (() -> Unit)? = null
) {
val windowSizeClass = LocalWindowSizeClass.current
val spacing = UiConstants.Spacing.medium(windowSizeClass)
@@ -27,6 +31,10 @@ fun CharacterHeader(characterData: CharacterData) {
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_CHARACTER_HEADER)
.then(
if (onEdit != null) Modifier.clickable { onEdit() }
else Modifier
)
) {
when (windowSizeClass) {
WindowSizeClass.Compact -> CompactHeader(characterData, spacing)

View File

@@ -59,6 +59,9 @@ fun CharacterSheetPage(
// Talent edit dialog state
var editingTalent by remember { mutableStateOf<TalentDefinition?>(null) }
// Character data edit dialog state
var editingCharacterData by remember { mutableStateOf(false) }
// Show dice roll result dialog
pendingRoll?.let { roll ->
DiceRollResultDialog(
@@ -99,6 +102,20 @@ fun CharacterSheetPage(
)
}
// Show character data edit dialog
if (editingCharacterData) {
CharacterDataEditDialog(
characterData = character.characterData,
onConfirm = { newData ->
onUpdateCharacter { char ->
char.copy(characterData = newData)
}
editingCharacterData = false
},
onDismiss = { editingCharacterData = false }
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Tab row
TabRow(
@@ -120,12 +137,15 @@ fun CharacterSheetPage(
val onEditTalent: (TalentDefinition) -> Unit = { talent ->
editingTalent = talent
}
val onEditCharacterData: () -> Unit = {
editingCharacterData = true
}
when (windowSizeClass) {
WindowSizeClass.Expanded -> {
// Expanded: two-column layout for Overview and Combat
when (selectedTab) {
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing)
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing)
@@ -133,7 +153,7 @@ fun CharacterSheetPage(
}
else -> {
when (selectedTab) {
CharacterTab.Overview -> OverviewContent(character, spacing)
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing)
@@ -144,7 +164,11 @@ fun CharacterSheetPage(
}
@Composable
private fun OverviewContent(character: ShadowrunCharacter, spacing: Dp) {
private fun OverviewContent(
character: ShadowrunCharacter,
spacing: Dp,
onEditCharacterData: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -152,14 +176,18 @@ private fun OverviewContent(character: ShadowrunCharacter, spacing: Dp) {
.padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing)
) {
CharacterHeader(character.characterData)
CharacterHeader(character.characterData, onEdit = onEditCharacterData)
ResourcePanel(character.characterData, character.attributes.edge)
DerivedAttributesPanel(character.attributes)
}
}
@Composable
private fun ExpandedOverviewContent(character: ShadowrunCharacter, spacing: Dp) {
private fun ExpandedOverviewContent(
character: ShadowrunCharacter,
spacing: Dp,
onEditCharacterData: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -167,7 +195,7 @@ private fun ExpandedOverviewContent(character: ShadowrunCharacter, spacing: Dp)
.padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing)
) {
CharacterHeader(character.characterData)
CharacterHeader(character.characterData, onEdit = onEditCharacterData)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing)