This commit was merged in pull request #113.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Issue #103: Character Background Notes and Lifestyle Tracking
|
||||
|
||||
## Summary
|
||||
Add free-text background/notes fields and lifestyle tracking (with multiple lifestyle support and monthly cost summary) to the Shadowrun character sheet. This requires new model classes, serialization support, UI panels on the Overview tab, and an edit dialog for lifestyles.
|
||||
|
||||
## Acceptance Criteria Checklist
|
||||
1. [ ] Free-text notes/background field on character sheet
|
||||
2. [ ] Lifestyle entries with name, level, and monthly cost
|
||||
3. [ ] Multiple lifestyles supported
|
||||
4. [ ] Total monthly cost displayed
|
||||
5. [ ] Serialization support for notes and lifestyle data
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Create Lifestyle Model
|
||||
- Create `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/characterdata/Lifestyle.kt`
|
||||
- `LifestyleLevel` enum: Street(0), Squatter(500), Low(2000), Middle(5000), High(10000), Luxury(100000) with monthly cost per Shadowrun 5e rules
|
||||
- `Lifestyle` data class: name (String), level (LifestyleLevel), monthlyCost (Int) — all @Serializable
|
||||
|
||||
### Step 2: Add Notes and Lifestyles to ShadowrunCharacter
|
||||
- Add `notes: String = ""` field to `ShadowrunCharacter`
|
||||
- Add `lifestyles: List<Lifestyle> = emptyList()` field to `ShadowrunCharacter`
|
||||
- Both with default values to maintain backward compatibility with existing serialized data
|
||||
|
||||
### Step 3: Update Defaults
|
||||
- Update `createNewCharacter()` and `EXAMPLE_CHARACTER` in Defaults.kt to include notes and lifestyles fields (can use defaults)
|
||||
|
||||
### Step 4: Create UI - NotesPanel
|
||||
- Create `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/NotesPanel.kt`
|
||||
- Card-based panel with a multiline text field for background/notes
|
||||
- Editable in-place with auto-save behavior (via onUpdateCharacter callback)
|
||||
|
||||
### Step 5: Create UI - LifestylePanel
|
||||
- Create `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/LifestylePanel.kt`
|
||||
- Card showing list of lifestyles with name, level, and monthly cost
|
||||
- Total monthly cost displayed at bottom
|
||||
- Add/remove lifestyle buttons
|
||||
- Edit dialog for adding/editing a lifestyle entry (name, level dropdown, optional cost override)
|
||||
|
||||
### Step 6: Integrate Panels into CharacterSheetPage
|
||||
- Add NotesPanel and LifestylePanel to the Overview tab in CharacterSheetPage.kt
|
||||
- Add to both OverviewContent and ExpandedOverviewContent
|
||||
|
||||
### Step 7: Add Test Tags
|
||||
- Add test tags for notes panel, lifestyle panel, and lifestyle entries to TestTags.kt
|
||||
|
||||
### Step 8: Update Serialization Test
|
||||
- Update SerializationRoundTripTest to verify notes and lifestyles survive serialization round-trip
|
||||
@@ -183,4 +183,24 @@ object TestTags {
|
||||
const val RECOVERY_STATE_BADGE = "recovery_state_badge"
|
||||
const val DEATH_WARNING = "death_warning"
|
||||
const val OVERFLOW_WARNING = "overflow_warning"
|
||||
|
||||
// --- Notes panel ---
|
||||
const val PANEL_NOTES = "panel_notes"
|
||||
const val NOTES_TEXT_FIELD = "notes_text_field"
|
||||
|
||||
// --- Lifestyle panel ---
|
||||
const val PANEL_LIFESTYLES = "panel_lifestyles"
|
||||
const val LIFESTYLE_TOTAL_COST = "lifestyle_total_cost"
|
||||
const val LIFESTYLE_ADD_BUTTON = "lifestyle_add_button"
|
||||
fun lifestyleEntry(index: Int): String = "lifestyle_entry_$index"
|
||||
fun lifestyleRemoveButton(index: Int): String = "lifestyle_remove_button_$index"
|
||||
fun lifestyleEditButton(index: Int): String = "lifestyle_edit_button_$index"
|
||||
|
||||
// --- Lifestyle edit dialog ---
|
||||
const val LIFESTYLE_EDIT_DIALOG = "lifestyle_edit_dialog"
|
||||
const val LIFESTYLE_EDIT_NAME = "lifestyle_edit_name"
|
||||
const val LIFESTYLE_EDIT_LEVEL = "lifestyle_edit_level"
|
||||
const val LIFESTYLE_EDIT_COST = "lifestyle_edit_cost"
|
||||
const val LIFESTYLE_EDIT_CONFIRM = "lifestyle_edit_confirm"
|
||||
const val LIFESTYLE_EDIT_DISMISS = "lifestyle_edit_dismiss"
|
||||
}
|
||||
|
||||
+39
-4
@@ -222,7 +222,7 @@ fun CharacterSheetPage(
|
||||
WindowSizeClass.Expanded -> {
|
||||
// Expanded: two-column layout for Overview and Combat
|
||||
when (selectedTab) {
|
||||
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData)
|
||||
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
|
||||
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
|
||||
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
|
||||
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
|
||||
@@ -231,7 +231,7 @@ fun CharacterSheetPage(
|
||||
else -> {
|
||||
|
||||
when (selectedTab) {
|
||||
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData)
|
||||
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
|
||||
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
|
||||
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
|
||||
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
|
||||
@@ -245,7 +245,8 @@ fun CharacterSheetPage(
|
||||
private fun OverviewContent(
|
||||
character: ShadowrunCharacter,
|
||||
spacing: Dp,
|
||||
onEditCharacterData: () -> Unit
|
||||
onEditCharacterData: () -> Unit,
|
||||
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -258,6 +259,18 @@ private fun OverviewContent(
|
||||
ResourcePanel(character.characterData, character.attributes.edge)
|
||||
DerivedAttributesPanel(character.attributes)
|
||||
ReputationPanel(character.characterData)
|
||||
LifestylePanel(
|
||||
lifestyles = character.lifestyles,
|
||||
onLifestylesChanged = { newLifestyles ->
|
||||
onUpdateCharacter { char -> char.copy(lifestyles = newLifestyles) }
|
||||
}
|
||||
)
|
||||
NotesPanel(
|
||||
notes = character.notes,
|
||||
onNotesChanged = { newNotes ->
|
||||
onUpdateCharacter { char -> char.copy(notes = newNotes) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +278,8 @@ private fun OverviewContent(
|
||||
private fun ExpandedOverviewContent(
|
||||
character: ShadowrunCharacter,
|
||||
spacing: Dp,
|
||||
onEditCharacterData: () -> Unit
|
||||
onEditCharacterData: () -> Unit,
|
||||
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -287,6 +301,27 @@ private fun ExpandedOverviewContent(
|
||||
}
|
||||
}
|
||||
ReputationPanel(character.characterData)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
LifestylePanel(
|
||||
lifestyles = character.lifestyles,
|
||||
onLifestylesChanged = { newLifestyles ->
|
||||
onUpdateCharacter { char -> char.copy(lifestyles = newLifestyles) }
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
NotesPanel(
|
||||
notes = character.notes,
|
||||
onNotesChanged = { newNotes ->
|
||||
onUpdateCharacter { char -> char.copy(notes = newNotes) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
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.characterdata.Lifestyle
|
||||
import org.shahondin1624.model.characterdata.LifestyleLevel
|
||||
import org.shahondin1624.theme.LocalWindowSizeClass
|
||||
|
||||
/**
|
||||
* Panel displaying the character's lifestyles with total monthly nuyen burn rate.
|
||||
* Supports adding, editing, and removing lifestyle entries.
|
||||
*/
|
||||
@Composable
|
||||
fun LifestylePanel(
|
||||
lifestyles: List<Lifestyle>,
|
||||
onLifestylesChanged: (List<Lifestyle>) -> Unit
|
||||
) {
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val padding = UiConstants.Spacing.medium(windowSizeClass)
|
||||
val totalMonthlyCost = lifestyles.sumOf { it.monthlyCost }
|
||||
|
||||
var editingIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Show edit dialog
|
||||
editingIndex?.let { index ->
|
||||
val lifestyle = lifestyles[index]
|
||||
LifestyleEditDialog(
|
||||
lifestyle = lifestyle,
|
||||
onConfirm = { updated ->
|
||||
val newList = lifestyles.toMutableList()
|
||||
newList[index] = updated
|
||||
onLifestylesChanged(newList)
|
||||
editingIndex = null
|
||||
},
|
||||
onDismiss = { editingIndex = null }
|
||||
)
|
||||
}
|
||||
|
||||
// Show add dialog
|
||||
if (showAddDialog) {
|
||||
LifestyleEditDialog(
|
||||
lifestyle = null,
|
||||
onConfirm = { newLifestyle ->
|
||||
onLifestylesChanged(lifestyles + newLifestyle)
|
||||
showAddDialog = false
|
||||
},
|
||||
onDismiss = { showAddDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.PANEL_LIFESTYLES)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Header row with title and add button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Lifestyles",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
IconButton(
|
||||
onClick = { showAddDialog = true },
|
||||
modifier = Modifier.testTag(TestTags.LIFESTYLE_ADD_BUTTON)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Lifestyle")
|
||||
}
|
||||
}
|
||||
|
||||
if (lifestyles.isEmpty()) {
|
||||
Text(
|
||||
text = "No lifestyles configured",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
// Lifestyle entries
|
||||
lifestyles.forEachIndexed { index, lifestyle ->
|
||||
LifestyleEntryRow(
|
||||
lifestyle = lifestyle,
|
||||
index = index,
|
||||
onEdit = { editingIndex = index },
|
||||
onRemove = {
|
||||
val newList = lifestyles.toMutableList()
|
||||
newList.removeAt(index)
|
||||
onLifestylesChanged(newList)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Total monthly cost
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.LIFESTYLE_TOTAL_COST),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Total Monthly Cost",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "${totalMonthlyCost}\u00A5/month",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LifestyleEntryRow(
|
||||
lifestyle: Lifestyle,
|
||||
index: Int,
|
||||
onEdit: () -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.lifestyleEntry(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 = lifestyle.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = lifestyle.level.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${lifestyle.monthlyCost}\u00A5",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onEdit,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.testTag(TestTags.lifestyleEditButton(index))
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.testTag(TestTags.lifestyleRemoveButton(index))
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for adding or editing a lifestyle entry.
|
||||
*
|
||||
* @param lifestyle The lifestyle to edit, or null when adding a new one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LifestyleEditDialog(
|
||||
lifestyle: Lifestyle?,
|
||||
onConfirm: (Lifestyle) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(lifestyle?.name ?: "") }
|
||||
var level by remember { mutableStateOf(lifestyle?.level ?: LifestyleLevel.Low) }
|
||||
var costText by remember { mutableStateOf(lifestyle?.monthlyCost?.toString() ?: level.monthlyCost.toString()) }
|
||||
var levelExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val cost = costText.toIntOrNull()
|
||||
val isValid = name.isNotBlank() && cost != null && cost >= 0
|
||||
|
||||
// When level changes and no custom cost was set, update cost to match level default
|
||||
val isAddMode = lifestyle == null
|
||||
val title = if (isAddMode) "Add Lifestyle" else "Edit Lifestyle"
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TestTags.LIFESTYLE_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("Name") },
|
||||
placeholder = { Text("e.g., Downtown Apartment") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.LIFESTYLE_EDIT_NAME)
|
||||
)
|
||||
|
||||
// Level dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = levelExpanded,
|
||||
onExpandedChange = { levelExpanded = it },
|
||||
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_LEVEL)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${level.name} (${level.monthlyCost}\u00A5/month)",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Level") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = levelExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = levelExpanded,
|
||||
onDismissRequest = { levelExpanded = false }
|
||||
) {
|
||||
LifestyleLevel.entries.forEach { ll ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${ll.name} (${ll.monthlyCost}\u00A5/month)") },
|
||||
onClick = {
|
||||
val previousLevelCost = level.monthlyCost
|
||||
level = ll
|
||||
// Auto-update cost if it matches the previous level's default
|
||||
if (costText == previousLevelCost.toString()) {
|
||||
costText = ll.monthlyCost.toString()
|
||||
}
|
||||
levelExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monthly cost (editable for custom amounts)
|
||||
OutlinedTextField(
|
||||
value = costText,
|
||||
onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) costText = it },
|
||||
label = { Text("Monthly Cost (\u00A5)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
isError = cost == null && costText.isNotEmpty(),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.LIFESTYLE_EDIT_COST)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
onConfirm(Lifestyle(name = name, level = level, monthlyCost = cost!!))
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_CONFIRM)
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_DISMISS)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package org.shahondin1624.lib.components.charactermodel
|
||||
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.shahondin1624.lib.components.TestTags
|
||||
import org.shahondin1624.lib.components.UiConstants
|
||||
import org.shahondin1624.theme.LocalWindowSizeClass
|
||||
|
||||
/**
|
||||
* Panel for free-text character background notes.
|
||||
* Provides an editable multiline text field for backstory,
|
||||
* personality notes, contacts, and other narrative information.
|
||||
*/
|
||||
@Composable
|
||||
fun NotesPanel(
|
||||
notes: String,
|
||||
onNotesChanged: (String) -> Unit
|
||||
) {
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val padding = UiConstants.Spacing.medium(windowSizeClass)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.PANEL_NOTES)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Background & Notes",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = onNotesChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp)
|
||||
.testTag(TestTags.NOTES_TEXT_FIELD),
|
||||
placeholder = { Text("Character backstory, personality, contacts, goals...") },
|
||||
minLines = 4,
|
||||
maxLines = 12
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.shahondin1624.model.characterdata
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Shadowrun 5e lifestyle levels with standard monthly costs in nuyen.
|
||||
*/
|
||||
@Serializable
|
||||
enum class LifestyleLevel(val monthlyCost: Int) {
|
||||
Street(0),
|
||||
Squatter(500),
|
||||
Low(2000),
|
||||
Middle(5000),
|
||||
High(10000),
|
||||
Luxury(100000)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single lifestyle entry. A character can maintain multiple lifestyles
|
||||
* (e.g., a primary residence and a safehouse).
|
||||
*
|
||||
* @param name Descriptive name (e.g., "Downtown Apartment", "Safehouse")
|
||||
* @param level The lifestyle level determining base monthly cost
|
||||
* @param monthlyCost Actual monthly cost in nuyen (defaults to the level's standard cost,
|
||||
* but can be overridden for customized lifestyles)
|
||||
*/
|
||||
@Serializable
|
||||
data class Lifestyle(
|
||||
val name: String,
|
||||
val level: LifestyleLevel,
|
||||
val monthlyCost: Int = level.monthlyCost
|
||||
)
|
||||
+3
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
import org.shahondin1624.model.Versionable
|
||||
import org.shahondin1624.model.attributes.Attributes
|
||||
import org.shahondin1624.model.characterdata.CharacterData
|
||||
import org.shahondin1624.model.characterdata.Lifestyle
|
||||
import org.shahondin1624.model.talents.Talents
|
||||
|
||||
@Serializable
|
||||
@@ -12,5 +13,7 @@ data class ShadowrunCharacter(
|
||||
val attributes: Attributes,
|
||||
val talents: Talents,
|
||||
val damageMonitor: DamageMonitor,
|
||||
val notes: String = "",
|
||||
val lifestyles: List<Lifestyle> = emptyList(),
|
||||
override val version: String = "v0.1"
|
||||
): Versionable
|
||||
|
||||
@@ -5,6 +5,8 @@ import org.shahondin1624.model.attributes.Attribute
|
||||
import org.shahondin1624.model.attributes.AttributeType
|
||||
import org.shahondin1624.model.attributes.Attributes
|
||||
import org.shahondin1624.model.characterdata.CharacterData
|
||||
import org.shahondin1624.model.characterdata.Lifestyle
|
||||
import org.shahondin1624.model.characterdata.LifestyleLevel
|
||||
import org.shahondin1624.model.characterdata.Metatype
|
||||
import org.shahondin1624.model.charactermodel.DamageMonitor
|
||||
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
|
||||
@@ -71,11 +73,18 @@ class SerializationRoundTripTest {
|
||||
overflowCurrent = 0
|
||||
)
|
||||
|
||||
val lifestyles = listOf(
|
||||
Lifestyle(name = "Downtown Apartment", level = LifestyleLevel.Middle),
|
||||
Lifestyle(name = "Safehouse", level = LifestyleLevel.Low, monthlyCost = 2500)
|
||||
)
|
||||
|
||||
val original = ShadowrunCharacter(
|
||||
characterData = characterData,
|
||||
attributes = attributes,
|
||||
talents = talents,
|
||||
damageMonitor = damageMonitor
|
||||
damageMonitor = damageMonitor,
|
||||
notes = "Street samurai background. Born in the Barrens.",
|
||||
lifestyles = lifestyles
|
||||
)
|
||||
|
||||
// Round-trip
|
||||
@@ -125,6 +134,18 @@ class SerializationRoundTripTest {
|
||||
assertEquals(original.damageMonitor.physicalCurrent(), restored.damageMonitor.physicalCurrent())
|
||||
assertEquals(original.damageMonitor.overflowCurrent(), restored.damageMonitor.overflowCurrent())
|
||||
|
||||
// Notes assertions
|
||||
assertEquals(original.notes, restored.notes)
|
||||
|
||||
// Lifestyle assertions
|
||||
assertEquals(original.lifestyles.size, restored.lifestyles.size)
|
||||
original.lifestyles.forEachIndexed { i, orig ->
|
||||
val rest = restored.lifestyles[i]
|
||||
assertEquals(orig.name, rest.name, "Lifestyle[$i].name")
|
||||
assertEquals(orig.level, rest.level, "Lifestyle[$i].level")
|
||||
assertEquals(orig.monthlyCost, rest.monthlyCost, "Lifestyle[$i].monthlyCost")
|
||||
}
|
||||
|
||||
// Version
|
||||
assertEquals(original.version, restored.version)
|
||||
}
|
||||
@@ -151,6 +172,57 @@ class SerializationRoundTripTest {
|
||||
assertEquals(original.version, restored.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notesAndLifestylesDefaultToEmptyOnDeserialization() {
|
||||
// Verify that characters without notes/lifestyles fields (backward compat) deserialize with defaults
|
||||
val original = org.shahondin1624.model.EXAMPLE_CHARACTER.copy(
|
||||
damageMonitor = DamageMonitor(
|
||||
attributes = org.shahondin1624.model.EXAMPLE_CHARACTER.attributes,
|
||||
stunCurrent = 0,
|
||||
physicalCurrent = 0,
|
||||
overflowCurrent = 0
|
||||
)
|
||||
)
|
||||
val json = DataLoader.serialize(original)
|
||||
val restored = DataLoader.deserialize(json)
|
||||
|
||||
assertEquals("", restored.notes, "Default notes should be empty string")
|
||||
assertEquals(emptyList(), restored.lifestyles, "Default lifestyles should be empty list")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lifestyleRoundTripWithMultipleEntries() {
|
||||
val lifestyles = listOf(
|
||||
Lifestyle(name = "Primary Residence", level = LifestyleLevel.High),
|
||||
Lifestyle(name = "Bolt Hole", level = LifestyleLevel.Squatter),
|
||||
Lifestyle(name = "Custom Place", level = LifestyleLevel.Low, monthlyCost = 3000)
|
||||
)
|
||||
val original = org.shahondin1624.model.EXAMPLE_CHARACTER.copy(
|
||||
damageMonitor = DamageMonitor(
|
||||
attributes = org.shahondin1624.model.EXAMPLE_CHARACTER.attributes,
|
||||
stunCurrent = 0,
|
||||
physicalCurrent = 0,
|
||||
overflowCurrent = 0
|
||||
),
|
||||
notes = "A long backstory\nwith multiple lines\nand special chars: !@#$%",
|
||||
lifestyles = lifestyles
|
||||
)
|
||||
|
||||
val json = DataLoader.serialize(original)
|
||||
val restored = DataLoader.deserialize(json)
|
||||
|
||||
assertEquals(original.notes, restored.notes)
|
||||
assertEquals(3, restored.lifestyles.size)
|
||||
assertEquals(LifestyleLevel.High, restored.lifestyles[0].level)
|
||||
assertEquals(10000, restored.lifestyles[0].monthlyCost)
|
||||
assertEquals(3000, restored.lifestyles[2].monthlyCost, "Custom cost should be preserved")
|
||||
assertEquals(
|
||||
lifestyles.sumOf { it.monthlyCost },
|
||||
restored.lifestyles.sumOf { it.monthlyCost },
|
||||
"Total monthly cost should match"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serializedJsonContainsAllFields() {
|
||||
val original = org.shahondin1624.model.EXAMPLE_CHARACTER
|
||||
@@ -163,6 +235,7 @@ class SerializationRoundTripTest {
|
||||
"body", "agility", "reaction", "strength",
|
||||
"willpower", "logic", "intuition", "charisma",
|
||||
"stunCurrent", "physicalCurrent", "overflowCurrent",
|
||||
"notes", "lifestyles",
|
||||
"version"
|
||||
)
|
||||
for (field in checks) {
|
||||
|
||||
Reference in New Issue
Block a user