feat: add gear, equipment, and weapons tracking system (Closes #93) (#135)

This commit was merged in pull request #135.
This commit is contained in:
2026-04-05 03:06:06 +02:00
parent 736e033269
commit 73c5a1190f
14 changed files with 1318 additions and 6 deletions
@@ -319,6 +319,61 @@
<string name="spell_sustained_label">Sustained</string>
<string name="spell_sustained_penalty">%1$d sustained spell(s): %2$d dice pool penalty</string>
<!-- Weapons -->
<string name="weapons_title">Weapons</string>
<string name="weapon_no_weapons">No weapons</string>
<string name="weapon_add_title">Add Weapon</string>
<string name="weapon_edit_title">Edit Weapon</string>
<string name="weapon_add_content_desc">Add weapon</string>
<string name="weapon_edit_content_desc">Edit weapon</string>
<string name="weapon_remove_content_desc">Remove weapon</string>
<string name="weapon_name_placeholder">e.g., Ares Predator V</string>
<string name="weapon_damage_value_label">Damage Value (DV)</string>
<string name="weapon_damage_value_placeholder">e.g., 8P</string>
<string name="weapon_ap_label">Armor Penetration (AP)</string>
<string name="weapon_firing_modes_label">Firing Modes</string>
<string name="weapon_ammo_capacity_label">Ammo Capacity</string>
<string name="weapon_current_ammo_label">Current Ammo</string>
<string name="weapon_description_label">Description</string>
<string name="weapon_description_placeholder">Optional notes</string>
<string name="weapon_dv_label">DV: %1$s</string>
<string name="weapon_ap_display">AP: %1$d</string>
<string name="weapon_ammo_display">Ammo: %1$d / %2$d</string>
<!-- Armor -->
<string name="armor_title">Armor</string>
<string name="armor_no_armor">No armor</string>
<string name="armor_add_title">Add Armor</string>
<string name="armor_edit_title">Edit Armor</string>
<string name="armor_add_content_desc">Add armor</string>
<string name="armor_edit_content_desc">Edit armor</string>
<string name="armor_remove_content_desc">Remove armor</string>
<string name="armor_name_placeholder">e.g., Armor Jacket</string>
<string name="armor_rating_label">Armor Rating</string>
<string name="armor_modifications_label">Modifications (comma-separated)</string>
<string name="armor_modifications_placeholder">e.g., Chemical Protection, Fire Resistance</string>
<string name="armor_description_label">Description</string>
<string name="armor_description_placeholder">Optional notes</string>
<string name="armor_rating_display">Rating: %1$d</string>
<string name="armor_total_rating">Total Armor Rating</string>
<!-- General Gear -->
<string name="gear_title">General Gear</string>
<string name="gear_no_gear">No gear</string>
<string name="gear_add_title">Add Gear</string>
<string name="gear_edit_title">Edit Gear</string>
<string name="gear_add_content_desc">Add gear</string>
<string name="gear_edit_content_desc">Edit gear</string>
<string name="gear_remove_content_desc">Remove gear</string>
<string name="gear_name_placeholder">e.g., Medkit (Rating 6)</string>
<string name="gear_quantity_label">Quantity</string>
<string name="gear_notes_label">Notes</string>
<string name="gear_notes_placeholder">Optional notes</string>
<string name="gear_quantity_display">x%1$d</string>
<!-- Gear tab -->
<string name="tab_gear">Gear</string>
<!-- Adept Powers -->
<string name="adept_powers_title">Adept Powers</string>
<string name="adept_power_no_powers">No adept powers active</string>
@@ -298,6 +298,57 @@ object TestTags {
const val ADEPT_POWER_EDIT_CONFIRM = "adept_power_edit_confirm"
const val ADEPT_POWER_EDIT_DISMISS = "adept_power_edit_dismiss"
// --- Weapon panel ---
const val PANEL_WEAPONS = "panel_weapons"
const val WEAPON_ADD_BUTTON = "weapon_add_button"
fun weaponEntry(index: Int): String = "weapon_entry_$index"
fun weaponEditButton(index: Int): String = "weapon_edit_button_$index"
fun weaponRemoveButton(index: Int): String = "weapon_remove_button_$index"
// --- Weapon edit dialog ---
const val WEAPON_EDIT_DIALOG = "weapon_edit_dialog"
const val WEAPON_EDIT_NAME = "weapon_edit_name"
const val WEAPON_EDIT_DAMAGE_VALUE = "weapon_edit_damage_value"
const val WEAPON_EDIT_AP = "weapon_edit_ap"
const val WEAPON_EDIT_AMMO_CAPACITY = "weapon_edit_ammo_capacity"
const val WEAPON_EDIT_CURRENT_AMMO = "weapon_edit_current_ammo"
const val WEAPON_EDIT_DESCRIPTION = "weapon_edit_description"
const val WEAPON_EDIT_CONFIRM = "weapon_edit_confirm"
const val WEAPON_EDIT_DISMISS = "weapon_edit_dismiss"
fun weaponFiringModeChip(mode: String): String = "weapon_firing_mode_${mode.lowercase()}"
// --- Armor panel ---
const val PANEL_ARMOR = "panel_armor"
const val ARMOR_ADD_BUTTON = "armor_add_button"
const val ARMOR_TOTAL_RATING = "armor_total_rating"
fun armorEntry(index: Int): String = "armor_entry_$index"
fun armorEditButton(index: Int): String = "armor_edit_button_$index"
fun armorRemoveButton(index: Int): String = "armor_remove_button_$index"
// --- Armor edit dialog ---
const val ARMOR_EDIT_DIALOG = "armor_edit_dialog"
const val ARMOR_EDIT_NAME = "armor_edit_name"
const val ARMOR_EDIT_RATING = "armor_edit_rating"
const val ARMOR_EDIT_MODIFICATIONS = "armor_edit_modifications"
const val ARMOR_EDIT_DESCRIPTION = "armor_edit_description"
const val ARMOR_EDIT_CONFIRM = "armor_edit_confirm"
const val ARMOR_EDIT_DISMISS = "armor_edit_dismiss"
// --- Gear item panel ---
const val PANEL_GEAR = "panel_gear"
const val GEAR_ADD_BUTTON = "gear_add_button"
fun gearEntry(index: Int): String = "gear_entry_$index"
fun gearEditButton(index: Int): String = "gear_edit_button_$index"
fun gearRemoveButton(index: Int): String = "gear_remove_button_$index"
// --- Gear item edit dialog ---
const val GEAR_EDIT_DIALOG = "gear_edit_dialog"
const val GEAR_EDIT_NAME = "gear_edit_name"
const val GEAR_EDIT_QUANTITY = "gear_edit_quantity"
const val GEAR_EDIT_NOTES = "gear_edit_notes"
const val GEAR_EDIT_CONFIRM = "gear_edit_confirm"
const val GEAR_EDIT_DISMISS = "gear_edit_dismiss"
// --- Lifestyle edit dialog ---
const val LIFESTYLE_EDIT_DIALOG = "lifestyle_edit_dialog"
const val LIFESTYLE_EDIT_NAME = "lifestyle_edit_name"
@@ -0,0 +1,329 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.gear.Armor
import org.jetbrains.compose.resources.stringResource
import org.shahondin1624.theme.LocalWindowSizeClass
import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.*
/**
* Panel displaying the character's armor entries.
* Shows total armor rating and supports adding, editing, and removing armor.
*/
@Composable
fun ArmorPanel(
armorList: List<Armor>,
totalArmorRating: Int,
onArmorChanged: (List<Armor>) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
var editingIndex by remember { mutableStateOf<Int?>(null) }
var showAddDialog by remember { mutableStateOf(false) }
// Show edit dialog
editingIndex?.let { index ->
val armor = armorList[index]
ArmorEditDialog(
armor = armor,
onConfirm = { updated ->
val newList = armorList.toMutableList()
newList[index] = updated
onArmorChanged(newList)
editingIndex = null
},
onDismiss = { editingIndex = null }
)
}
// Show add dialog
if (showAddDialog) {
ArmorEditDialog(
armor = null,
onConfirm = { newArmor ->
onArmorChanged(armorList + newArmor)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_ARMOR)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.armor_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { showAddDialog = true },
modifier = Modifier.testTag(TestTags.ARMOR_ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.armor_add_content_desc))
}
}
if (armorList.isEmpty()) {
Text(
text = stringResource(Res.string.armor_no_armor),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
armorList.forEachIndexed { index, armor ->
ArmorEntryRow(
armor = armor,
index = index,
onEdit = { editingIndex = index },
onRemove = {
val newList = armorList.toMutableList()
newList.removeAt(index)
onArmorChanged(newList)
}
)
}
// Total armor rating
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ARMOR_TOTAL_RATING),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(Res.string.armor_total_rating),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = totalArmorRating.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
@Composable
private fun ArmorEntryRow(
armor: Armor,
index: Int,
onEdit: () -> Unit,
onRemove: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.armorEntry(index)),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = armor.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = stringResource(Res.string.armor_rating_display, armor.rating),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
if (armor.modifications.isNotEmpty()) {
Text(
text = armor.modifications.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = onEdit,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.armorEditButton(index))
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.armor_edit_content_desc),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.armorRemoveButton(index))
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.armor_remove_content_desc),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Dialog for adding or editing an armor entry.
*/
@Composable
fun ArmorEditDialog(
armor: Armor?,
onConfirm: (Armor) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(armor?.name ?: "") }
var ratingText by remember { mutableStateOf(armor?.rating?.toString() ?: "0") }
var modificationsText by remember { mutableStateOf(armor?.modifications?.joinToString(", ") ?: "") }
var description by remember { mutableStateOf(armor?.description ?: "") }
val rating = ratingText.toIntOrNull()
val isValid = name.isNotBlank() && rating != null && rating >= 0
val isAddMode = armor == null
val title = if (isAddMode) stringResource(Res.string.armor_add_title) else stringResource(Res.string.armor_edit_title)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.ARMOR_EDIT_DIALOG),
title = { Text(title) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.label_name)) },
placeholder = { Text(stringResource(Res.string.armor_name_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ARMOR_EDIT_NAME)
)
// Rating
OutlinedTextField(
value = ratingText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue.matches(Regex("^\\d+$"))) {
ratingText = newValue
}
},
label = { Text(stringResource(Res.string.armor_rating_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ARMOR_EDIT_RATING)
)
// Modifications
OutlinedTextField(
value = modificationsText,
onValueChange = { modificationsText = it },
label = { Text(stringResource(Res.string.armor_modifications_label)) },
placeholder = { Text(stringResource(Res.string.armor_modifications_placeholder)) },
maxLines = 2,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ARMOR_EDIT_MODIFICATIONS)
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.armor_description_label)) },
placeholder = { Text(stringResource(Res.string.armor_description_placeholder)) },
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ARMOR_EDIT_DESCRIPTION)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
val mods = modificationsText
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
onConfirm(
Armor(
name = name,
rating = rating!!,
modifications = mods,
description = description
)
)
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.ARMOR_EDIT_CONFIRM)
) {
Text(stringResource(Res.string.save))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.ARMOR_EDIT_DISMISS)
) {
Text(stringResource(Res.string.cancel))
}
}
)
}
@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.charactermodel.attributespage.Attribute
import org.shahondin1624.lib.components.charactermodel.attributespage.AttributeEditDialog
@@ -39,7 +40,8 @@ private enum class CharacterTab(val titleRes: StringResource) {
Attributes(Res.string.tab_attributes),
Talents(Res.string.tab_talents),
Magic(Res.string.tab_magic),
Combat(Res.string.tab_combat)
Combat(Res.string.tab_combat),
Gear(Res.string.tab_gear)
}
/**
@@ -225,8 +227,9 @@ fun CharacterSheetPage(
Column(modifier = Modifier.fillMaxSize()) {
// Tab row
TabRow(
selectedTabIndex = selectedTab.ordinal
ScrollableTabRow(
selectedTabIndex = selectedTab.ordinal,
edgePadding = 0.dp
) {
CharacterTab.entries.forEach { tab ->
Tab(
@@ -257,6 +260,7 @@ fun CharacterSheetPage(
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter)
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter)
}
}
else -> {
@@ -267,6 +271,7 @@ fun CharacterSheetPage(
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter)
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter)
}
}
}
@@ -512,3 +517,38 @@ private fun CombatContent(
)
}
}
@Composable
private fun GearContent(
character: ShadowrunCharacter,
spacing: Dp,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing)
) {
WeaponPanel(
weapons = character.weapons,
onWeaponsChanged = { newWeapons ->
onUpdateCharacter { char -> char.copy(weapons = newWeapons) }
}
)
ArmorPanel(
armorList = character.armor,
totalArmorRating = character.totalArmorRating(),
onArmorChanged = { newArmor ->
onUpdateCharacter { char -> char.copy(armor = newArmor) }
}
)
GearItemPanel(
gearItems = character.gear,
onGearChanged = { newGear ->
onUpdateCharacter { char -> char.copy(gear = newGear) }
}
)
}
}
@@ -0,0 +1,292 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.gear.GearItem
import org.jetbrains.compose.resources.stringResource
import org.shahondin1624.theme.LocalWindowSizeClass
import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.*
/**
* Panel displaying the character's general gear/equipment.
* Supports adding, editing, and removing gear entries.
*/
@Composable
fun GearItemPanel(
gearItems: List<GearItem>,
onGearChanged: (List<GearItem>) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
var editingIndex by remember { mutableStateOf<Int?>(null) }
var showAddDialog by remember { mutableStateOf(false) }
// Show edit dialog
editingIndex?.let { index ->
val item = gearItems[index]
GearItemEditDialog(
gearItem = item,
onConfirm = { updated ->
val newList = gearItems.toMutableList()
newList[index] = updated
onGearChanged(newList)
editingIndex = null
},
onDismiss = { editingIndex = null }
)
}
// Show add dialog
if (showAddDialog) {
GearItemEditDialog(
gearItem = null,
onConfirm = { newItem ->
onGearChanged(gearItems + newItem)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_GEAR)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.gear_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { showAddDialog = true },
modifier = Modifier.testTag(TestTags.GEAR_ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.gear_add_content_desc))
}
}
if (gearItems.isEmpty()) {
Text(
text = stringResource(Res.string.gear_no_gear),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
gearItems.forEachIndexed { index, item ->
GearItemEntryRow(
gearItem = item,
index = index,
onEdit = { editingIndex = index },
onRemove = {
val newList = gearItems.toMutableList()
newList.removeAt(index)
onGearChanged(newList)
}
)
}
}
}
}
}
@Composable
private fun GearItemEntryRow(
gearItem: GearItem,
index: Int,
onEdit: () -> Unit,
onRemove: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.gearEntry(index)),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = gearItem.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (gearItem.notes.isNotBlank()) {
Text(
text = gearItem.notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (gearItem.quantity > 1) {
Text(
text = stringResource(Res.string.gear_quantity_display, gearItem.quantity),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
IconButton(
onClick = onEdit,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.gearEditButton(index))
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.gear_edit_content_desc),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.gearRemoveButton(index))
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.gear_remove_content_desc),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Dialog for adding or editing a gear item entry.
*/
@Composable
fun GearItemEditDialog(
gearItem: GearItem?,
onConfirm: (GearItem) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(gearItem?.name ?: "") }
var quantityText by remember { mutableStateOf(gearItem?.quantity?.toString() ?: "1") }
var notes by remember { mutableStateOf(gearItem?.notes ?: "") }
val quantity = quantityText.toIntOrNull()
val isValid = name.isNotBlank() && quantity != null && quantity >= 1
val isAddMode = gearItem == null
val title = if (isAddMode) stringResource(Res.string.gear_add_title) else stringResource(Res.string.gear_edit_title)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.GEAR_EDIT_DIALOG),
title = { Text(title) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.label_name)) },
placeholder = { Text(stringResource(Res.string.gear_name_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.GEAR_EDIT_NAME)
)
// Quantity
OutlinedTextField(
value = quantityText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue.matches(Regex("^\\d+$"))) {
quantityText = newValue
}
},
label = { Text(stringResource(Res.string.gear_quantity_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.GEAR_EDIT_QUANTITY)
)
// Notes
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text(stringResource(Res.string.gear_notes_label)) },
placeholder = { Text(stringResource(Res.string.gear_notes_placeholder)) },
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.GEAR_EDIT_NOTES)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
onConfirm(
GearItem(
name = name,
quantity = quantity!!,
notes = notes
)
)
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.GEAR_EDIT_CONFIRM)
) {
Text(stringResource(Res.string.save))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.GEAR_EDIT_DISMISS)
) {
Text(stringResource(Res.string.cancel))
}
}
)
}
@@ -0,0 +1,388 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.gear.FiringMode
import org.shahondin1624.model.gear.Weapon
import org.jetbrains.compose.resources.stringResource
import org.shahondin1624.theme.LocalWindowSizeClass
import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.*
/**
* Panel displaying the character's weapons.
* Supports adding, editing, and removing weapon entries.
*/
@Composable
fun WeaponPanel(
weapons: List<Weapon>,
onWeaponsChanged: (List<Weapon>) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
var editingIndex by remember { mutableStateOf<Int?>(null) }
var showAddDialog by remember { mutableStateOf(false) }
// Show edit dialog
editingIndex?.let { index ->
val weapon = weapons[index]
WeaponEditDialog(
weapon = weapon,
onConfirm = { updated ->
val newList = weapons.toMutableList()
newList[index] = updated
onWeaponsChanged(newList)
editingIndex = null
},
onDismiss = { editingIndex = null }
)
}
// Show add dialog
if (showAddDialog) {
WeaponEditDialog(
weapon = null,
onConfirm = { newWeapon ->
onWeaponsChanged(weapons + newWeapon)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_WEAPONS)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.weapons_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { showAddDialog = true },
modifier = Modifier.testTag(TestTags.WEAPON_ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.weapon_add_content_desc))
}
}
if (weapons.isEmpty()) {
Text(
text = stringResource(Res.string.weapon_no_weapons),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
weapons.forEachIndexed { index, weapon ->
WeaponEntryRow(
weapon = weapon,
index = index,
onEdit = { editingIndex = index },
onRemove = {
val newList = weapons.toMutableList()
newList.removeAt(index)
onWeaponsChanged(newList)
}
)
}
}
}
}
}
@Composable
private fun WeaponEntryRow(
weapon: Weapon,
index: Int,
onEdit: () -> Unit,
onRemove: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.weaponEntry(index)),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = weapon.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(Res.string.weapon_dv_label, weapon.damageValue),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(Res.string.weapon_ap_display, weapon.armorPenetration),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (weapon.firingModes.isNotEmpty()) {
Text(
text = weapon.firingModes.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (weapon.ammoCapacity > 0) {
Text(
text = stringResource(Res.string.weapon_ammo_display, weapon.currentAmmo, weapon.ammoCapacity),
style = MaterialTheme.typography.bodySmall,
color = if (weapon.currentAmmo == 0) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = onEdit,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.weaponEditButton(index))
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.weapon_edit_content_desc),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.weaponRemoveButton(index))
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.weapon_remove_content_desc),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Dialog for adding or editing a weapon entry.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeaponEditDialog(
weapon: Weapon?,
onConfirm: (Weapon) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(weapon?.name ?: "") }
var damageValue by remember { mutableStateOf(weapon?.damageValue ?: "") }
var apText by remember { mutableStateOf(weapon?.armorPenetration?.toString() ?: "0") }
var firingModes by remember { mutableStateOf(weapon?.firingModes ?: emptyList()) }
var ammoCapacityText by remember { mutableStateOf(weapon?.ammoCapacity?.toString() ?: "0") }
var currentAmmoText by remember { mutableStateOf(weapon?.currentAmmo?.toString() ?: "0") }
var description by remember { mutableStateOf(weapon?.description ?: "") }
val ap = apText.toIntOrNull()
val ammoCapacity = ammoCapacityText.toIntOrNull()
val currentAmmo = currentAmmoText.toIntOrNull()
val isValid = name.isNotBlank() && damageValue.isNotBlank() && ap != null
&& ammoCapacity != null && ammoCapacity >= 0
&& currentAmmo != null && currentAmmo >= 0
val isAddMode = weapon == null
val title = if (isAddMode) stringResource(Res.string.weapon_add_title) else stringResource(Res.string.weapon_edit_title)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.WEAPON_EDIT_DIALOG),
title = { Text(title) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.label_name)) },
placeholder = { Text(stringResource(Res.string.weapon_name_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WEAPON_EDIT_NAME)
)
// Damage Value
OutlinedTextField(
value = damageValue,
onValueChange = { damageValue = it },
label = { Text(stringResource(Res.string.weapon_damage_value_label)) },
placeholder = { Text(stringResource(Res.string.weapon_damage_value_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WEAPON_EDIT_DAMAGE_VALUE)
)
// Armor Penetration
OutlinedTextField(
value = apText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue == "-" || newValue.matches(Regex("^-?\\d+$"))) {
apText = newValue
}
},
label = { Text(stringResource(Res.string.weapon_ap_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WEAPON_EDIT_AP)
)
// Firing Modes
Text(
text = stringResource(Res.string.weapon_firing_modes_label),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
FiringMode.entries.forEach { mode ->
FilterChip(
selected = mode in firingModes,
onClick = {
firingModes = if (mode in firingModes) {
firingModes - mode
} else {
firingModes + mode
}
},
label = { Text(mode.name, style = MaterialTheme.typography.labelSmall) },
modifier = Modifier.testTag(TestTags.weaponFiringModeChip(mode.name))
)
}
}
// Ammo Capacity
OutlinedTextField(
value = ammoCapacityText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue.matches(Regex("^\\d+$"))) {
ammoCapacityText = newValue
}
},
label = { Text(stringResource(Res.string.weapon_ammo_capacity_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WEAPON_EDIT_AMMO_CAPACITY)
)
// Current Ammo
OutlinedTextField(
value = currentAmmoText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue.matches(Regex("^\\d+$"))) {
currentAmmoText = newValue
}
},
label = { Text(stringResource(Res.string.weapon_current_ammo_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WEAPON_EDIT_CURRENT_AMMO)
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.weapon_description_label)) },
placeholder = { Text(stringResource(Res.string.weapon_description_placeholder)) },
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WEAPON_EDIT_DESCRIPTION)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
onConfirm(
Weapon(
name = name,
damageValue = damageValue,
armorPenetration = ap!!,
firingModes = firingModes,
ammoCapacity = ammoCapacity!!,
currentAmmo = currentAmmo!!.coerceAtMost(ammoCapacity),
description = description
)
)
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.WEAPON_EDIT_CONFIRM)
) {
Text(stringResource(Res.string.save))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.WEAPON_EDIT_DISMISS)
) {
Text(stringResource(Res.string.cancel))
}
}
)
}
@@ -6,6 +6,9 @@ import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.characterdata.Augmentation
import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Lifestyle
import org.shahondin1624.model.gear.Armor
import org.shahondin1624.model.gear.GearItem
import org.shahondin1624.model.gear.Weapon
import org.shahondin1624.model.magic.AdeptPower
import org.shahondin1624.model.magic.Spell
import org.shahondin1624.model.migration.SchemaVersion
@@ -22,6 +25,9 @@ data class ShadowrunCharacter(
val augmentations: List<Augmentation> = emptyList(),
val spells: List<Spell> = emptyList(),
val adeptPowers: List<AdeptPower> = emptyList(),
val weapons: List<Weapon> = emptyList(),
val armor: List<Armor> = emptyList(),
val gear: List<GearItem> = emptyList(),
override val version: String = SchemaVersion.CURRENT
): Versionable {
/**
@@ -51,4 +57,11 @@ data class ShadowrunCharacter(
* Each sustained spell applies a -2 dice pool penalty.
*/
fun activeSustainedSpells(): Int = spells.count { it.sustained }
/**
* The highest armor rating from all worn armor pieces.
* In Shadowrun 5e, only the highest armor rating applies as the base,
* though other pieces may provide bonuses in specific situations.
*/
fun totalArmorRating(): Int = armor.maxOfOrNull { it.rating } ?: 0
}
@@ -0,0 +1,22 @@
package org.shahondin1624.model.gear
import kotlinx.serialization.Serializable
/**
* Represents an armor entry on the character sheet.
*
* In Shadowrun 5e, armor provides a rating that is used to resist damage.
* Armor can also have modifications (e.g., chemical protection, fire resistance).
*
* @param name The name of the armor (e.g., "Armor Jacket", "Lined Coat")
* @param rating The armor rating value
* @param modifications List of modification names installed on this armor
* @param description Optional notes about the armor
*/
@Serializable
data class Armor(
val name: String,
val rating: Int,
val modifications: List<String> = emptyList(),
val description: String = ""
)
@@ -0,0 +1,20 @@
package org.shahondin1624.model.gear
import kotlinx.serialization.Serializable
/**
* Represents a general equipment/gear entry on the character sheet.
*
* Used for miscellaneous items that don't fit into weapons or armor categories,
* such as commlinks, tools, medkits, credsticks, etc.
*
* @param name The name of the item (e.g., "Medkit (Rating 6)", "Commlink")
* @param quantity How many of this item the character carries
* @param notes Optional notes about the item
*/
@Serializable
data class GearItem(
val name: String,
val quantity: Int = 1,
val notes: String = ""
)
@@ -0,0 +1,40 @@
package org.shahondin1624.model.gear
import kotlinx.serialization.Serializable
/**
* Firing mode for ranged weapons in Shadowrun 5e.
*/
@Serializable
enum class FiringMode {
SingleShot,
SemiAutomatic,
BurstFire,
FullAutomatic
}
/**
* Represents a weapon entry on the character sheet.
*
* In Shadowrun 5e, weapons have a Damage Value (DV) expressed as a string
* (e.g., "8P" for 8 Physical, "6S" for 6 Stun), an Armor Penetration (AP)
* value, available firing modes for ranged weapons, and ammunition tracking.
*
* @param name The name of the weapon (e.g., "Ares Predator V", "Combat Knife")
* @param damageValue The damage value string (e.g., "8P", "6S", "(STR+3)P")
* @param armorPenetration The armor penetration value (negative means it pierces armor)
* @param firingModes Available firing modes for ranged weapons; empty for melee weapons
* @param ammoCapacity Maximum ammunition capacity; 0 for melee weapons
* @param currentAmmo Current ammunition count
* @param description Optional notes about the weapon
*/
@Serializable
data class Weapon(
val name: String,
val damageValue: String,
val armorPenetration: Int = 0,
val firingModes: List<FiringMode> = emptyList(),
val ammoCapacity: Int = 0,
val currentAmmo: Int = 0,
val description: String = ""
)
@@ -12,7 +12,8 @@ object MigrationRegistry {
private val migrations: List<VersionMigration> = listOf(
MigrationV01ToV02(),
MigrationV02ToV03()
MigrationV02ToV03(),
MigrationV03ToV04()
)
/**
@@ -0,0 +1,57 @@
package org.shahondin1624.model.migration
import kotlinx.serialization.json.*
/**
* Migration from schema v0.3 to v0.4.
*
* Changes in v0.4:
* - Adds "weapons" field to ShadowrunCharacter (default: [])
* - Adds "armor" field to ShadowrunCharacter (default: [])
* - Adds "gear" field to ShadowrunCharacter (default: [])
* - Updates all version fields from "v0.3" to "v0.4"
*/
class MigrationV03ToV04 : VersionMigration {
override val fromVersion: String = SchemaVersion.V0_3
override val toVersion: String = SchemaVersion.V0_4
override fun migrate(json: JsonObject): JsonObject {
val mutable = json.toMutableMap()
// Add weapons list if missing
if ("weapons" !in mutable) {
mutable["weapons"] = JsonArray(emptyList())
}
// Add armor list if missing
if ("armor" !in mutable) {
mutable["armor"] = JsonArray(emptyList())
}
// Add gear list if missing
if ("gear" !in mutable) {
mutable["gear"] = JsonArray(emptyList())
}
// Update version fields at all levels
mutable["version"] = JsonPrimitive(toVersion)
mutable.updateNestedVersion("characterData")
mutable.updateNestedVersion("attributes")
mutable.updateNestedVersion("talents")
mutable.updateNestedVersion("damageMonitor")
return JsonObject(mutable)
}
/**
* Update the "version" field inside a nested JSON object.
*/
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.3"
const val CURRENT: String = "v0.4"
/**
* Initial version used before the migration system was introduced.
@@ -15,4 +15,6 @@ object SchemaVersion {
const val V0_2: String = "v0.2"
const val V0_3: String = "v0.3"
const val V0_4: String = "v0.4"
}
@@ -132,11 +132,13 @@ class VersionMigrationTest {
@Test
fun findMigrationChainReturnsPathFromV01() {
val chain = MigrationRegistry.findMigrationChain("v0.1")
assertEquals(2, chain.size)
assertEquals(3, 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)
}
@Test