This commit was merged in pull request #136.
This commit is contained in:
@@ -137,6 +137,38 @@
|
||||
<string name="talent_min_rating_error">Minimum rating is 0</string>
|
||||
<string name="talent_max_rating_error">Maximum rating is %1$d</string>
|
||||
|
||||
<!-- Skill categories -->
|
||||
<string name="skill_category_active">Active Skills</string>
|
||||
<string name="skill_category_knowledge">Knowledge Skills</string>
|
||||
<string name="skill_category_language">Language Skills</string>
|
||||
<string name="skill_specialization_label">Specialization (optional)</string>
|
||||
<string name="skill_specialization_placeholder">e.g., Pistols</string>
|
||||
<string name="skill_roll_with_spec">Roll with specialization (+2)</string>
|
||||
<string name="skill_roll_without_spec">Roll without specialization</string>
|
||||
<string name="skill_untrained_default">Untrained (default)</string>
|
||||
<string name="skill_add_knowledge">Add Knowledge Skill</string>
|
||||
<string name="skill_add_language">Add Language Skill</string>
|
||||
<string name="skill_knowledge_category_label">Category</string>
|
||||
<string name="skill_language_proficiency_label">Proficiency</string>
|
||||
<string name="skill_add_knowledge_title">Add Knowledge Skill</string>
|
||||
<string name="skill_add_language_title">Add Language Skill</string>
|
||||
<string name="skill_name_label">Skill Name</string>
|
||||
<string name="skill_name_placeholder">e.g., Corporate Politics</string>
|
||||
<string name="skill_language_name_placeholder">e.g., Japanese</string>
|
||||
<string name="skill_remove_content_desc">Remove skill</string>
|
||||
|
||||
<!-- Knowledge categories -->
|
||||
<string name="knowledge_academic">Academic</string>
|
||||
<string name="knowledge_interest">Interest</string>
|
||||
<string name="knowledge_professional">Professional</string>
|
||||
<string name="knowledge_street">Street</string>
|
||||
|
||||
<!-- Language proficiencies -->
|
||||
<string name="language_basic">Basic</string>
|
||||
<string name="language_specialist">Specialist</string>
|
||||
<string name="language_expert">Expert</string>
|
||||
<string name="language_native">Native</string>
|
||||
|
||||
<!-- Dice roll content descriptions -->
|
||||
<string name="roll_dice_content_desc">Roll dice</string>
|
||||
|
||||
|
||||
@@ -64,6 +64,24 @@ object TestTags {
|
||||
const val TALENT_EDIT_CONFIRM = "talent_edit_confirm"
|
||||
const val TALENT_EDIT_DISMISS = "talent_edit_dismiss"
|
||||
const val TALENT_EDIT_ERROR = "talent_edit_error"
|
||||
const val TALENT_EDIT_SPECIALIZATION = "talent_edit_specialization"
|
||||
|
||||
// --- Knowledge/Language skill dialogs ---
|
||||
const val KNOWLEDGE_SKILL_ADD_DIALOG = "knowledge_skill_add_dialog"
|
||||
const val KNOWLEDGE_SKILL_NAME_INPUT = "knowledge_skill_name_input"
|
||||
const val KNOWLEDGE_SKILL_CATEGORY = "knowledge_skill_category"
|
||||
const val KNOWLEDGE_SKILL_CONFIRM = "knowledge_skill_confirm"
|
||||
const val KNOWLEDGE_SKILL_DISMISS = "knowledge_skill_dismiss"
|
||||
const val KNOWLEDGE_SKILL_ADD_BUTTON = "knowledge_skill_add_button"
|
||||
|
||||
const val LANGUAGE_SKILL_ADD_DIALOG = "language_skill_add_dialog"
|
||||
const val LANGUAGE_SKILL_NAME_INPUT = "language_skill_name_input"
|
||||
const val LANGUAGE_SKILL_PROFICIENCY = "language_skill_proficiency"
|
||||
const val LANGUAGE_SKILL_CONFIRM = "language_skill_confirm"
|
||||
const val LANGUAGE_SKILL_DISMISS = "language_skill_dismiss"
|
||||
const val LANGUAGE_SKILL_ADD_BUTTON = "language_skill_add_button"
|
||||
|
||||
fun skillRemoveButton(name: String): String = "skill_remove_${name.lowercase().replace(" ", "_")}"
|
||||
|
||||
// --- Dice roll result dialog ---
|
||||
const val DICE_ROLL_DIALOG = "dice_roll_dialog"
|
||||
|
||||
+152
-8
@@ -7,14 +7,20 @@ 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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.shahondin1624.lib.components.TestTags
|
||||
import org.shahondin1624.lib.components.UiConstants
|
||||
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.TalentEditDialog
|
||||
import org.shahondin1624.model.talents.TalentDefinition
|
||||
import org.shahondin1624.model.talents.createKnowledgeSkill
|
||||
import org.shahondin1624.model.talents.createLanguageSkill
|
||||
import org.shahondin1624.lib.components.charactermodel.attributespage.KnowledgeSkillDialog
|
||||
import org.shahondin1624.lib.components.charactermodel.attributespage.LanguageSkillDialog
|
||||
import org.shahondin1624.lib.functions.DiceRoll
|
||||
import org.shahondin1624.lib.functions.performExtendedTest
|
||||
import org.shahondin1624.lib.functions.performOpposedTest
|
||||
@@ -200,9 +206,11 @@ fun CharacterSheetPage(
|
||||
editingTalent?.let { talent ->
|
||||
TalentEditDialog(
|
||||
talent = talent,
|
||||
onConfirm = { newValue ->
|
||||
onConfirm = { newValue, specialization ->
|
||||
onUpdateCharacter { char ->
|
||||
val newTalents = char.talents.withTalentRating(talent.name, newValue)
|
||||
val newTalents = char.talents.withTalentRatingAndSpecialization(
|
||||
talent.name, newValue, specialization
|
||||
)
|
||||
char.copy(talents = newTalents)
|
||||
}
|
||||
editingTalent = null
|
||||
@@ -257,7 +265,7 @@ fun CharacterSheetPage(
|
||||
when (selectedTab) {
|
||||
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
|
||||
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
|
||||
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
|
||||
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent, onUpdateCharacter)
|
||||
CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter)
|
||||
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
|
||||
CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter)
|
||||
@@ -268,7 +276,7 @@ fun CharacterSheetPage(
|
||||
when (selectedTab) {
|
||||
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
|
||||
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
|
||||
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
|
||||
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent, onUpdateCharacter)
|
||||
CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter)
|
||||
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
|
||||
CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter)
|
||||
@@ -424,13 +432,43 @@ private fun TalentsContent(
|
||||
character: ShadowrunCharacter,
|
||||
spacing: Dp,
|
||||
onDiceRoll: (DiceRoll, String) -> Unit,
|
||||
onEditTalent: (TalentDefinition) -> Unit
|
||||
onEditTalent: (TalentDefinition) -> Unit,
|
||||
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {}
|
||||
) {
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val totalCols = UiConstants.Grid.totalColumns(windowSizeClass)
|
||||
val talentSpan = UiConstants.Grid.talentSpan(windowSizeClass)
|
||||
val talentsPerRow = totalCols / talentSpan
|
||||
|
||||
var showAddKnowledgeDialog by remember { mutableStateOf(false) }
|
||||
var showAddLanguageDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showAddKnowledgeDialog) {
|
||||
KnowledgeSkillDialog(
|
||||
onConfirm = { name, category ->
|
||||
onUpdateCharacter { char ->
|
||||
val newTalent = createKnowledgeSkill(name, category)
|
||||
char.copy(talents = char.talents.withAddedTalent(newTalent))
|
||||
}
|
||||
showAddKnowledgeDialog = false
|
||||
},
|
||||
onDismiss = { showAddKnowledgeDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddLanguageDialog) {
|
||||
LanguageSkillDialog(
|
||||
onConfirm = { name, proficiency ->
|
||||
onUpdateCharacter { char ->
|
||||
val newTalent = createLanguageSkill(name, proficiency)
|
||||
char.copy(talents = char.talents.withAddedTalent(newTalent))
|
||||
}
|
||||
showAddLanguageDialog = false
|
||||
},
|
||||
onDismiss = { showAddLanguageDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -438,9 +476,15 @@ private fun TalentsContent(
|
||||
.padding(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
val talents = character.talents.talents
|
||||
val rows = talents.chunked(talentsPerRow)
|
||||
for (row in rows) {
|
||||
// Active Skills section
|
||||
Text(
|
||||
text = stringResource(Res.string.skill_category_active),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
val activeSkills = character.talents.activeSkills()
|
||||
val activeRows = activeSkills.chunked(talentsPerRow)
|
||||
for (row in activeRows) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
@@ -460,6 +504,106 @@ private fun TalentsContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing))
|
||||
|
||||
// Knowledge Skills section
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.skill_category_knowledge),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showAddKnowledgeDialog = true },
|
||||
modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_ADD_BUTTON)
|
||||
) {
|
||||
Text(stringResource(Res.string.skill_add_knowledge))
|
||||
}
|
||||
}
|
||||
val knowledgeSkills = character.talents.knowledgeSkills()
|
||||
if (knowledgeSkills.isEmpty()) {
|
||||
Text(
|
||||
text = "No knowledge skills",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
val knowledgeRows = knowledgeSkills.chunked(talentsPerRow)
|
||||
for (row in knowledgeRows) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
for (talent in row) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Talent(
|
||||
talent = talent,
|
||||
attributes = character.attributes,
|
||||
onRoll = { roll -> onDiceRoll(roll, talent.name) },
|
||||
onEdit = { onEditTalent(talent) }
|
||||
)
|
||||
}
|
||||
}
|
||||
repeat(talentsPerRow - row.size) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing))
|
||||
|
||||
// Language Skills section
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.skill_category_language),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showAddLanguageDialog = true },
|
||||
modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_ADD_BUTTON)
|
||||
) {
|
||||
Text(stringResource(Res.string.skill_add_language))
|
||||
}
|
||||
}
|
||||
val languageSkills = character.talents.languageSkills()
|
||||
if (languageSkills.isEmpty()) {
|
||||
Text(
|
||||
text = "No language skills",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
val languageRows = languageSkills.chunked(talentsPerRow)
|
||||
for (row in languageRows) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
for (talent in row) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Talent(
|
||||
talent = talent,
|
||||
attributes = character.attributes,
|
||||
onRoll = { roll -> onDiceRoll(roll, talent.name) },
|
||||
onEdit = { onEditTalent(talent) }
|
||||
)
|
||||
}
|
||||
}
|
||||
repeat(talentsPerRow - row.size) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package org.shahondin1624.lib.components.charactermodel.attributespage
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.shahondin1624.lib.components.TestTags
|
||||
import org.shahondin1624.model.talents.KnowledgeCategory
|
||||
import shadowruncharsheet.sharedui.generated.resources.Res
|
||||
import shadowruncharsheet.sharedui.generated.resources.*
|
||||
|
||||
/**
|
||||
* Dialog for adding a new knowledge skill.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun KnowledgeSkillDialog(
|
||||
onConfirm: (name: String, category: KnowledgeCategory) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf(KnowledgeCategory.ACADEMIC) }
|
||||
var categoryExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val isValid = name.isNotBlank()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_ADD_DIALOG),
|
||||
title = {
|
||||
Text(text = stringResource(Res.string.skill_add_knowledge_title))
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(Res.string.skill_name_label)) },
|
||||
placeholder = { Text(stringResource(Res.string.skill_name_placeholder)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.KNOWLEDGE_SKILL_NAME_INPUT)
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = categoryExpanded,
|
||||
onExpandedChange = { categoryExpanded = it },
|
||||
modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_CATEGORY)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = knowledgeCategoryLabel(selectedCategory),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(Res.string.skill_knowledge_category_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = categoryExpanded,
|
||||
onDismissRequest = { categoryExpanded = false }
|
||||
) {
|
||||
KnowledgeCategory.entries.forEach { cat ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(knowledgeCategoryLabel(cat)) },
|
||||
onClick = {
|
||||
selectedCategory = cat
|
||||
categoryExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (isValid) onConfirm(name.trim(), selectedCategory) },
|
||||
enabled = isValid,
|
||||
modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_CONFIRM)
|
||||
) {
|
||||
Text(stringResource(Res.string.create))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_DISMISS)
|
||||
) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun knowledgeCategoryLabel(category: KnowledgeCategory): String {
|
||||
return when (category) {
|
||||
KnowledgeCategory.ACADEMIC -> stringResource(Res.string.knowledge_academic)
|
||||
KnowledgeCategory.INTEREST -> stringResource(Res.string.knowledge_interest)
|
||||
KnowledgeCategory.PROFESSIONAL -> stringResource(Res.string.knowledge_professional)
|
||||
KnowledgeCategory.STREET -> stringResource(Res.string.knowledge_street)
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package org.shahondin1624.lib.components.charactermodel.attributespage
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.shahondin1624.lib.components.TestTags
|
||||
import org.shahondin1624.model.talents.LanguageProficiency
|
||||
import shadowruncharsheet.sharedui.generated.resources.Res
|
||||
import shadowruncharsheet.sharedui.generated.resources.*
|
||||
|
||||
/**
|
||||
* Dialog for adding a new language skill.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LanguageSkillDialog(
|
||||
onConfirm: (name: String, proficiency: LanguageProficiency) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var selectedProficiency by remember { mutableStateOf(LanguageProficiency.BASIC) }
|
||||
var proficiencyExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val isValid = name.isNotBlank()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_ADD_DIALOG),
|
||||
title = {
|
||||
Text(text = stringResource(Res.string.skill_add_language_title))
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(Res.string.skill_name_label)) },
|
||||
placeholder = { Text(stringResource(Res.string.skill_language_name_placeholder)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.LANGUAGE_SKILL_NAME_INPUT)
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = proficiencyExpanded,
|
||||
onExpandedChange = { proficiencyExpanded = it },
|
||||
modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_PROFICIENCY)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = languageProficiencyLabel(selectedProficiency),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(Res.string.skill_language_proficiency_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = proficiencyExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = proficiencyExpanded,
|
||||
onDismissRequest = { proficiencyExpanded = false }
|
||||
) {
|
||||
LanguageProficiency.entries.forEach { prof ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(languageProficiencyLabel(prof)) },
|
||||
onClick = {
|
||||
selectedProficiency = prof
|
||||
proficiencyExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (isValid) onConfirm(name.trim(), selectedProficiency) },
|
||||
enabled = isValid,
|
||||
modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_CONFIRM)
|
||||
) {
|
||||
Text(stringResource(Res.string.create))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_DISMISS)
|
||||
) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun languageProficiencyLabel(proficiency: LanguageProficiency): String {
|
||||
return when (proficiency) {
|
||||
LanguageProficiency.BASIC -> stringResource(Res.string.language_basic)
|
||||
LanguageProficiency.SPECIALIST -> stringResource(Res.string.language_specialist)
|
||||
LanguageProficiency.EXPERT -> stringResource(Res.string.language_expert)
|
||||
LanguageProficiency.NATIVE -> stringResource(Res.string.language_native)
|
||||
}
|
||||
}
|
||||
+55
-4
@@ -11,10 +11,16 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -30,6 +36,7 @@ import org.shahondin1624.lib.components.TestTags
|
||||
import org.shahondin1624.lib.components.UiConstants.SMALL_PADDING
|
||||
import org.shahondin1624.lib.functions.DiceRoll
|
||||
import org.shahondin1624.model.attributes.Attributes
|
||||
import org.shahondin1624.model.talents.Talent as TalentModel
|
||||
import org.shahondin1624.model.talents.TalentDefinition
|
||||
import org.shahondin1624.theme.contrastTextColor
|
||||
import shadowruncharsheet.sharedui.generated.resources.Res
|
||||
@@ -76,6 +83,9 @@ private fun TalentCardContent(
|
||||
textColor: Color,
|
||||
onRoll: (DiceRoll) -> Unit
|
||||
) {
|
||||
var showSpecMenu by remember { mutableStateOf(false) }
|
||||
val hasSpec = talent.specialization != null
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
@@ -94,7 +104,7 @@ private fun TalentCardContent(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = talent.name,
|
||||
text = if (talent is TalentModel) talent.displayName() else talent.name,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = SMALL_PADDING),
|
||||
@@ -114,7 +124,11 @@ private fun TalentCardContent(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = talent.value.toString(),
|
||||
text = if (talent.value == 0) {
|
||||
stringResource(Res.string.skill_untrained_default)
|
||||
} else {
|
||||
talent.value.toString()
|
||||
},
|
||||
modifier = Modifier
|
||||
.widthIn(min = 24.dp)
|
||||
.padding(end = SMALL_PADDING),
|
||||
@@ -126,8 +140,16 @@ private fun TalentCardContent(
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
val result = talent.test(modifiers = emptyList(), attributes = attributes)
|
||||
onRoll(result)
|
||||
if (hasSpec) {
|
||||
showSpecMenu = true
|
||||
} else {
|
||||
val result = talent.test(
|
||||
modifiers = emptyList(),
|
||||
attributes = attributes,
|
||||
withSpecialization = false
|
||||
)
|
||||
onRoll(result)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = SMALL_PADDING)
|
||||
@@ -141,6 +163,35 @@ private fun TalentCardContent(
|
||||
),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showSpecMenu,
|
||||
onDismissRequest = { showSpecMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.skill_roll_without_spec)) },
|
||||
onClick = {
|
||||
showSpecMenu = false
|
||||
val result = talent.test(
|
||||
modifiers = emptyList(),
|
||||
attributes = attributes,
|
||||
withSpecialization = false
|
||||
)
|
||||
onRoll(result)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.skill_roll_with_spec)) },
|
||||
onClick = {
|
||||
showSpecMenu = false
|
||||
val result = talent.test(
|
||||
modifiers = emptyList(),
|
||||
attributes = attributes,
|
||||
withSpecialization = true
|
||||
)
|
||||
onRoll(result)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-3
@@ -15,17 +15,18 @@ import shadowruncharsheet.sharedui.generated.resources.Res
|
||||
import shadowruncharsheet.sharedui.generated.resources.*
|
||||
|
||||
/**
|
||||
* Dialog for editing a talent's rating.
|
||||
* Dialog for editing a talent's rating and optional specialization.
|
||||
* Rating range is 0-12; 0 means untrained.
|
||||
*/
|
||||
@Composable
|
||||
fun TalentEditDialog(
|
||||
talent: TalentDefinition,
|
||||
maxValue: Int = 12,
|
||||
onConfirm: (Int) -> Unit,
|
||||
onConfirm: (Int, String?) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var textValue by remember { mutableStateOf(talent.value.toString()) }
|
||||
var specValue by remember { mutableStateOf(talent.specialization ?: "") }
|
||||
val parsedValue = textValue.toIntOrNull()
|
||||
val isValid = parsedValue != null && parsedValue in 0..maxValue
|
||||
|
||||
@@ -68,11 +69,27 @@ fun TalentEditDialog(
|
||||
modifier = Modifier.testTag(TestTags.TALENT_EDIT_ERROR)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = specValue,
|
||||
onValueChange = { specValue = it },
|
||||
label = { Text(stringResource(Res.string.skill_specialization_label)) },
|
||||
placeholder = { Text(stringResource(Res.string.skill_specialization_placeholder)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.TALENT_EDIT_SPECIALIZATION)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (isValid) onConfirm(parsedValue!!) },
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
val spec = specValue.takeIf { it.isNotBlank() }
|
||||
onConfirm(parsedValue!!, spec)
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
modifier = Modifier.testTag(TestTags.TALENT_EDIT_CONFIRM)
|
||||
) {
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ object MigrationRegistry {
|
||||
private val migrations: List<VersionMigration> = listOf(
|
||||
MigrationV01ToV02(),
|
||||
MigrationV02ToV03(),
|
||||
MigrationV03ToV04()
|
||||
MigrationV03ToV04(),
|
||||
MigrationV04ToV05()
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.shahondin1624.model.migration
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
/**
|
||||
* Migration from schema v0.4 to v0.5.
|
||||
*
|
||||
* Changes in v0.5:
|
||||
* - Adds "specialization" field (null) to all talent entries
|
||||
* - Adds "category" field ("ACTIVE") to all talent entries
|
||||
* - Adds "knowledgeCategory" field (null) to all talent entries
|
||||
* - Adds "languageProficiency" field (null) to all talent entries
|
||||
* - Updates all version fields from "v0.4" to "v0.5"
|
||||
*/
|
||||
class MigrationV04ToV05 : VersionMigration {
|
||||
override val fromVersion: String = SchemaVersion.V0_4
|
||||
override val toVersion: String = SchemaVersion.V0_5
|
||||
|
||||
override fun migrate(json: JsonObject): JsonObject {
|
||||
val mutable = json.toMutableMap()
|
||||
|
||||
// Migrate talent entries to add new fields
|
||||
val talents = mutable["talents"]
|
||||
if (talents is JsonObject) {
|
||||
val talentsMutable = talents.toMutableMap()
|
||||
val talentList = talentsMutable["talents"]
|
||||
if (talentList is JsonArray) {
|
||||
val migratedTalents = talentList.map { talentElement ->
|
||||
if (talentElement is JsonObject) {
|
||||
migrateTalent(talentElement)
|
||||
} else {
|
||||
talentElement
|
||||
}
|
||||
}
|
||||
talentsMutable["talents"] = JsonArray(migratedTalents)
|
||||
}
|
||||
talentsMutable["version"] = JsonPrimitive(toVersion)
|
||||
mutable["talents"] = JsonObject(talentsMutable)
|
||||
}
|
||||
|
||||
// Update version fields at all levels
|
||||
mutable["version"] = JsonPrimitive(toVersion)
|
||||
mutable.updateNestedVersion("characterData")
|
||||
mutable.updateNestedVersion("attributes")
|
||||
mutable.updateNestedVersion("damageMonitor")
|
||||
|
||||
return JsonObject(mutable)
|
||||
}
|
||||
|
||||
private fun migrateTalent(talent: JsonObject): JsonObject {
|
||||
val mutable = talent.toMutableMap()
|
||||
|
||||
if ("specialization" !in mutable) {
|
||||
mutable["specialization"] = JsonNull
|
||||
}
|
||||
if ("category" !in mutable) {
|
||||
mutable["category"] = JsonPrimitive("ACTIVE")
|
||||
}
|
||||
if ("knowledgeCategory" !in mutable) {
|
||||
mutable["knowledgeCategory"] = JsonNull
|
||||
}
|
||||
if ("languageProficiency" !in mutable) {
|
||||
mutable["languageProficiency"] = JsonNull
|
||||
}
|
||||
|
||||
return JsonObject(mutable)
|
||||
}
|
||||
|
||||
private fun MutableMap<String, JsonElement>.updateNestedVersion(key: String) {
|
||||
val nested = this[key]
|
||||
if (nested is JsonObject) {
|
||||
val nestedMutable = nested.toMutableMap()
|
||||
nestedMutable["version"] = JsonPrimitive(toVersion)
|
||||
this[key] = JsonObject(nestedMutable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ package org.shahondin1624.model.migration
|
||||
* as their default version value.
|
||||
*/
|
||||
object SchemaVersion {
|
||||
const val CURRENT: String = "v0.4"
|
||||
const val CURRENT: String = "v0.5"
|
||||
|
||||
/**
|
||||
* Initial version used before the migration system was introduced.
|
||||
@@ -17,4 +17,6 @@ object SchemaVersion {
|
||||
const val V0_3: String = "v0.3"
|
||||
|
||||
const val V0_4: String = "v0.4"
|
||||
|
||||
const val V0_5: String = "v0.5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.shahondin1624.model.talents
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Sub-categories for knowledge skills in Shadowrun 5e.
|
||||
*/
|
||||
@Serializable
|
||||
enum class KnowledgeCategory {
|
||||
ACADEMIC,
|
||||
INTEREST,
|
||||
PROFESSIONAL,
|
||||
STREET
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.shahondin1624.model.talents
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Proficiency levels for language skills in Shadowrun 5e.
|
||||
*/
|
||||
@Serializable
|
||||
enum class LanguageProficiency {
|
||||
BASIC,
|
||||
SPECIALIST,
|
||||
EXPERT,
|
||||
NATIVE
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.shahondin1624.model.talents
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Categorizes skills according to Shadowrun 5e rules.
|
||||
* - ACTIVE: Combat, social, technical, vehicle, and magical skills
|
||||
* - KNOWLEDGE: Academic, interest, professional, and street knowledge
|
||||
* - LANGUAGE: Language skills with proficiency levels
|
||||
*/
|
||||
@Serializable
|
||||
enum class SkillCategory {
|
||||
ACTIVE,
|
||||
KNOWLEDGE,
|
||||
LANGUAGE
|
||||
}
|
||||
@@ -15,11 +15,42 @@ data class Talent(
|
||||
override val attribute: AttributeType,
|
||||
override val value: Int,
|
||||
override val custom: Boolean,
|
||||
override val specialization: String? = null,
|
||||
override val category: SkillCategory = SkillCategory.ACTIVE,
|
||||
override val knowledgeCategory: KnowledgeCategory? = null,
|
||||
override val languageProficiency: LanguageProficiency? = null,
|
||||
): TalentDefinition {
|
||||
override fun test(modifiers: List<SRModifier<*>>, attributes: Attributes): DiceRoll {
|
||||
override fun test(
|
||||
modifiers: List<SRModifier<*>>,
|
||||
attributes: Attributes,
|
||||
withSpecialization: Boolean
|
||||
): DiceRoll {
|
||||
val attributeValue = attributes.getAttributeByType(attribute).value
|
||||
val combined = value + attributeValue
|
||||
return modifiers.accumulateModifiers(DiceRoll(numberOfDice = combined)).roll()
|
||||
val specBonus = if (withSpecialization && specialization != null) 2 else 0
|
||||
val pool = if (value == 0) {
|
||||
defaultPool(attributes)
|
||||
} else {
|
||||
value + attributeValue + specBonus
|
||||
}
|
||||
return modifiers.accumulateModifiers(DiceRoll(numberOfDice = pool.coerceAtLeast(0))).roll()
|
||||
}
|
||||
|
||||
override fun defaultPool(attributes: Attributes): Int {
|
||||
return when (category) {
|
||||
SkillCategory.ACTIVE -> {
|
||||
(attributes.getAttributeByType(attribute).value - 1).coerceAtLeast(0)
|
||||
}
|
||||
SkillCategory.KNOWLEDGE, SkillCategory.LANGUAGE -> {
|
||||
(attributes.getAttributeByType(AttributeType.Intuition).value - 1).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name including specialization, e.g., "Firearms (Pistols)"
|
||||
*/
|
||||
fun displayName(): String {
|
||||
return if (specialization != null) "$name ($specialization)" else name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +58,38 @@ private fun createProvidedTalent(provided: ProvidedTalentName, value: Int = 0) =
|
||||
name = provided.name.replace("_", " "),
|
||||
attribute = provided.attribute,
|
||||
value = value,
|
||||
custom = false
|
||||
custom = false,
|
||||
category = SkillCategory.ACTIVE
|
||||
)
|
||||
|
||||
fun createAllProvidedTalents() = ProvidedTalentName.entries.map {
|
||||
createProvidedTalent(it)
|
||||
}
|
||||
|
||||
fun createCustomTalent(name: String, attribute: AttributeType, value: Int = 0) = Talent(name, attribute, value, true)
|
||||
fun createCustomTalent(name: String, attribute: AttributeType, value: Int = 0) = Talent(name, attribute, value, true)
|
||||
|
||||
fun createKnowledgeSkill(
|
||||
name: String,
|
||||
knowledgeCategory: KnowledgeCategory,
|
||||
value: Int = 0
|
||||
) = Talent(
|
||||
name = name,
|
||||
attribute = AttributeType.Intuition,
|
||||
value = value,
|
||||
custom = true,
|
||||
category = SkillCategory.KNOWLEDGE,
|
||||
knowledgeCategory = knowledgeCategory
|
||||
)
|
||||
|
||||
fun createLanguageSkill(
|
||||
name: String,
|
||||
proficiency: LanguageProficiency,
|
||||
value: Int = 0
|
||||
) = Talent(
|
||||
name = name,
|
||||
attribute = AttributeType.Intuition,
|
||||
value = value,
|
||||
custom = true,
|
||||
category = SkillCategory.LANGUAGE,
|
||||
languageProficiency = proficiency
|
||||
)
|
||||
|
||||
@@ -12,5 +12,25 @@ sealed interface TalentDefinition {
|
||||
val attribute: AttributeType
|
||||
val value: Int
|
||||
val custom: Boolean
|
||||
fun test(modifiers: List<SRModifier<*>>, attributes: Attributes): DiceRoll
|
||||
}
|
||||
val specialization: String?
|
||||
val category: SkillCategory
|
||||
val knowledgeCategory: KnowledgeCategory?
|
||||
val languageProficiency: LanguageProficiency?
|
||||
|
||||
/**
|
||||
* Roll a skill test. When [withSpecialization] is true and a specialization
|
||||
* is set, adds +2 dice to the pool per Shadowrun 5e rules.
|
||||
*/
|
||||
fun test(
|
||||
modifiers: List<SRModifier<*>>,
|
||||
attributes: Attributes,
|
||||
withSpecialization: Boolean = false
|
||||
): DiceRoll
|
||||
|
||||
/**
|
||||
* Calculate the default dice pool for an untrained test (skill rating 0).
|
||||
* Active skills: linked attribute - 1 (minimum 0).
|
||||
* Knowledge/Language skills: Intuition - 1 (minimum 0).
|
||||
*/
|
||||
fun defaultPool(attributes: Attributes): Int
|
||||
}
|
||||
|
||||
@@ -16,11 +16,39 @@ data class Talents(
|
||||
return copy(
|
||||
talents = talents.map { talent ->
|
||||
if (talent.name == talentName) {
|
||||
Talent(
|
||||
name = talent.name,
|
||||
attribute = talent.attribute,
|
||||
(talent as Talent).copy(value = newValue)
|
||||
} else {
|
||||
talent
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the specified talent's specialization updated.
|
||||
*/
|
||||
fun withTalentSpecialization(talentName: String, specialization: String?): Talents {
|
||||
return copy(
|
||||
talents = talents.map { talent ->
|
||||
if (talent.name == talentName) {
|
||||
(talent as Talent).copy(specialization = specialization?.takeIf { it.isNotBlank() })
|
||||
} else {
|
||||
talent
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the specified talent's rating and specialization updated together.
|
||||
*/
|
||||
fun withTalentRatingAndSpecialization(talentName: String, newValue: Int, specialization: String?): Talents {
|
||||
return copy(
|
||||
talents = talents.map { talent ->
|
||||
if (talent.name == talentName) {
|
||||
(talent as Talent).copy(
|
||||
value = newValue,
|
||||
custom = talent.custom
|
||||
specialization = specialization?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
talent
|
||||
@@ -28,4 +56,27 @@ data class Talents(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new talent (knowledge or language skill) to the list.
|
||||
*/
|
||||
fun withAddedTalent(talent: TalentDefinition): Talents {
|
||||
return copy(talents = talents + talent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a talent by name. Only custom talents can be removed.
|
||||
*/
|
||||
fun withRemovedTalent(talentName: String): Talents {
|
||||
return copy(talents = talents.filter { it.name != talentName || !it.custom })
|
||||
}
|
||||
|
||||
/** All active skills (the default Shadowrun skill list). */
|
||||
fun activeSkills(): List<TalentDefinition> = talents.filter { it.category == SkillCategory.ACTIVE }
|
||||
|
||||
/** All knowledge skills. */
|
||||
fun knowledgeSkills(): List<TalentDefinition> = talents.filter { it.category == SkillCategory.KNOWLEDGE }
|
||||
|
||||
/** All language skills. */
|
||||
fun languageSkills(): List<TalentDefinition> = talents.filter { it.category == SkillCategory.LANGUAGE }
|
||||
}
|
||||
|
||||
@@ -132,13 +132,15 @@ class VersionMigrationTest {
|
||||
@Test
|
||||
fun findMigrationChainReturnsPathFromV01() {
|
||||
val chain = MigrationRegistry.findMigrationChain("v0.1")
|
||||
assertEquals(3, chain.size)
|
||||
assertEquals(4, chain.size)
|
||||
assertEquals("v0.1", chain[0].fromVersion)
|
||||
assertEquals("v0.2", chain[0].toVersion)
|
||||
assertEquals("v0.2", chain[1].fromVersion)
|
||||
assertEquals("v0.3", chain[1].toVersion)
|
||||
assertEquals("v0.3", chain[2].fromVersion)
|
||||
assertEquals("v0.4", chain[2].toVersion)
|
||||
assertEquals("v0.4", chain[3].fromVersion)
|
||||
assertEquals("v0.5", chain[3].toVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user