This commit was merged in pull request #135.
This commit is contained in:
@@ -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"
|
||||
|
||||
+329
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+43
-3
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+292
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+388
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+13
@@ -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 = ""
|
||||
)
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user