diff --git a/sharedUI/src/commonMain/composeResources/values/strings.xml b/sharedUI/src/commonMain/composeResources/values/strings.xml
index 983e751..83bdff0 100644
--- a/sharedUI/src/commonMain/composeResources/values/strings.xml
+++ b/sharedUI/src/commonMain/composeResources/values/strings.xml
@@ -137,6 +137,38 @@
Minimum rating is 0
Maximum rating is %1$d
+
+ Active Skills
+ Knowledge Skills
+ Language Skills
+ Specialization (optional)
+ e.g., Pistols
+ Roll with specialization (+2)
+ Roll without specialization
+ Untrained (default)
+ Add Knowledge Skill
+ Add Language Skill
+ Category
+ Proficiency
+ Add Knowledge Skill
+ Add Language Skill
+ Skill Name
+ e.g., Corporate Politics
+ e.g., Japanese
+ Remove skill
+
+
+ Academic
+ Interest
+ Professional
+ Street
+
+
+ Basic
+ Specialist
+ Expert
+ Native
+
Roll dice
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt
index 6f7337c..4d0e554 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt
@@ -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"
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt
index 5c28bb2..930b428 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt
@@ -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))
+ }
+ }
+ }
+ }
}
}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/KnowledgeSkillDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/KnowledgeSkillDialog.kt
new file mode 100644
index 0000000..5e5fa42
--- /dev/null
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/KnowledgeSkillDialog.kt
@@ -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)
+ }
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/LanguageSkillDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/LanguageSkillDialog.kt
new file mode 100644
index 0000000..3486e7b
--- /dev/null
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/LanguageSkillDialog.kt
@@ -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)
+ }
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt
index 7255a00..712b98a 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt
@@ -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)
+ }
+ )
+ }
}
}
}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt
index ee89629..c969d35 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt
@@ -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)
) {
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt
index 3c13590..d124317 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt
@@ -13,7 +13,8 @@ object MigrationRegistry {
private val migrations: List = listOf(
MigrationV01ToV02(),
MigrationV02ToV03(),
- MigrationV03ToV04()
+ MigrationV03ToV04(),
+ MigrationV04ToV05()
)
/**
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV04ToV05.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV04ToV05.kt
new file mode 100644
index 0000000..ceab68c
--- /dev/null
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV04ToV05.kt
@@ -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.updateNestedVersion(key: String) {
+ val nested = this[key]
+ if (nested is JsonObject) {
+ val nestedMutable = nested.toMutableMap()
+ nestedMutable["version"] = JsonPrimitive(toVersion)
+ this[key] = JsonObject(nestedMutable)
+ }
+ }
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt
index 843ec4e..a092148 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt
@@ -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"
}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/KnowledgeCategory.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/KnowledgeCategory.kt
new file mode 100644
index 0000000..0a0df00
--- /dev/null
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/KnowledgeCategory.kt
@@ -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
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/LanguageProficiency.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/LanguageProficiency.kt
new file mode 100644
index 0000000..3706db5
--- /dev/null
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/LanguageProficiency.kt
@@ -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
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/SkillCategory.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/SkillCategory.kt
new file mode 100644
index 0000000..a8e6a29
--- /dev/null
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/SkillCategory.kt
@@ -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
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt
index 71ae872..b487353 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt
@@ -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>, attributes: Attributes): DiceRoll {
+ override fun test(
+ modifiers: List>,
+ 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)
\ No newline at end of file
+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
+)
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt
index 8772d2c..780c443 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt
@@ -12,5 +12,25 @@ sealed interface TalentDefinition {
val attribute: AttributeType
val value: Int
val custom: Boolean
- fun test(modifiers: List>, attributes: Attributes): DiceRoll
-}
\ No newline at end of file
+ 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>,
+ 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
+}
diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt
index 1c2597a..10d71d8 100644
--- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt
+++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt
@@ -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 = talents.filter { it.category == SkillCategory.ACTIVE }
+
+ /** All knowledge skills. */
+ fun knowledgeSkills(): List = talents.filter { it.category == SkillCategory.KNOWLEDGE }
+
+ /** All language skills. */
+ fun languageSkills(): List = talents.filter { it.category == SkillCategory.LANGUAGE }
}
diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt
index 7e56cb6..7255cee 100644
--- a/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt
+++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt
@@ -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