feat: add Export/Import character as JSON on Settings page (Closes #29)

Export serializes the current character via DataLoader.serialize() and
shows it in a scrollable monospace text dialog. Import accepts pasted
JSON, validates via DataLoader.deserialize(), and shows an error message
for invalid input. Both dialogs are accessible from the Settings page
and work on all platforms without platform-specific file I/O.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-13 14:14:53 +01:00
parent fb2149b0a7
commit 0d00130e51
5 changed files with 265 additions and 6 deletions

View File

@@ -320,7 +320,12 @@ private fun MainScaffold(
)
}
composable(AppRoutes.SETTINGS) {
SettingsPage()
SettingsPage(
character = character,
onImportCharacter = { imported ->
characterViewModel.setCharacter(imported)
}
)
}
}
}

View File

@@ -75,6 +75,20 @@ object TestTags {
const val SETTINGS_THEME_SYSTEM = "settings_theme_system"
const val SETTINGS_THEME_LIGHT = "settings_theme_light"
const val SETTINGS_THEME_DARK = "settings_theme_dark"
const val SETTINGS_EXPORT_BUTTON = "settings_export_button"
const val SETTINGS_IMPORT_BUTTON = "settings_import_button"
// --- Export dialog ---
const val EXPORT_DIALOG = "export_dialog"
const val EXPORT_JSON_TEXT = "export_json_text"
const val EXPORT_DISMISS = "export_dismiss"
// --- Import dialog ---
const val IMPORT_DIALOG = "import_dialog"
const val IMPORT_JSON_INPUT = "import_json_input"
const val IMPORT_CONFIRM = "import_confirm"
const val IMPORT_DISMISS = "import_dismiss"
const val IMPORT_ERROR = "import_error"
// --- Top app bar ---
const val TOP_APP_BAR = "top_app_bar"

View File

@@ -0,0 +1,65 @@
package org.shahondin1624.lib.components.settings
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
/**
* Dialog displaying the character JSON for export.
* The user can select and copy the text manually.
*/
@Composable
fun ExportCharacterDialog(
json: String,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.EXPORT_DIALOG),
title = { Text("Export Character") },
text = {
Column {
Text(
text = "Copy the JSON below to save your character:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = MaterialTheme.shapes.small,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
) {
Text(
text = json,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
.padding(8.dp)
.testTag(TestTags.EXPORT_JSON_TEXT)
)
}
}
},
confirmButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.EXPORT_DISMISS)
) {
Text("Close")
}
}
)
}

View File

@@ -0,0 +1,87 @@
package org.shahondin1624.lib.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.functions.DataLoader
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
/**
* Dialog for importing a character from pasted JSON.
* Validates the JSON and shows an error message for invalid input.
*/
@Composable
fun ImportCharacterDialog(
onImport: (ShadowrunCharacter) -> Unit,
onDismiss: () -> Unit
) {
var jsonText by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.IMPORT_DIALOG),
title = { Text("Import Character") },
text = {
Column {
Text(
text = "Paste the character JSON below:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = jsonText,
onValueChange = {
jsonText = it
errorMessage = null
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 150.dp, max = 300.dp)
.testTag(TestTags.IMPORT_JSON_INPUT),
placeholder = { Text("{ \"characterData\": ... }") },
isError = errorMessage != null,
maxLines = Int.MAX_VALUE
)
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(top = 4.dp)
.testTag(TestTags.IMPORT_ERROR)
)
}
}
},
confirmButton = {
TextButton(
onClick = {
try {
val character = DataLoader.deserialize(jsonText)
onImport(character)
} catch (e: Exception) {
errorMessage = "Invalid JSON: ${e.message?.take(100) ?: "unknown error"}"
}
},
modifier = Modifier.testTag(TestTags.IMPORT_CONFIRM),
enabled = jsonText.isNotBlank()
) {
Text("Import")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.IMPORT_DISMISS)
) {
Text("Cancel")
}
}
)
}

View File

@@ -1,12 +1,15 @@
package org.shahondin1624.lib.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@@ -14,23 +17,52 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.functions.DataLoader
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.theme.LocalThemePreference
import org.shahondin1624.theme.ThemePreference
/**
* Settings page with theme selection (Light / Dark / System Default).
* Settings page with theme selection and character export/import.
*/
@Composable
fun SettingsPage() {
fun SettingsPage(
character: ShadowrunCharacter? = null,
onImportCharacter: ((ShadowrunCharacter) -> Unit)? = null
) {
var themePreference by LocalThemePreference.current
var showExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
// Export dialog
if (showExportDialog && character != null) {
val json = remember(character) { DataLoader.serialize(character) }
ExportCharacterDialog(
json = json,
onDismiss = { showExportDialog = false }
)
}
// Import dialog
if (showImportDialog && onImportCharacter != null) {
ImportCharacterDialog(
onImport = { importedCharacter ->
onImportCharacter(importedCharacter)
showImportDialog = false
},
onDismiss = { showImportDialog = false }
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
.testTag(TestTags.SETTINGS_PAGE),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// --- Appearance section ---
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium,
@@ -78,6 +110,62 @@ fun SettingsPage() {
)
}
}
// --- Character Data section ---
if (character != null && onImportCharacter != null) {
Text(
text = "Character Data",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = { showExportDialog = true },
modifier = Modifier
.weight(1f)
.testTag(TestTags.SETTINGS_EXPORT_BUTTON)
) {
Icon(
Icons.Default.Upload,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Export")
}
OutlinedButton(
onClick = { showImportDialog = true },
modifier = Modifier
.weight(1f)
.testTag(TestTags.SETTINGS_IMPORT_BUTTON)
) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Import")
}
}
}
}
}
}
}