feat: add Export/Import character as JSON (Closes #29) (#72)

This commit was merged in pull request #72.
This commit is contained in:
2026-03-13 14:15:10 +01:00
parent fb2149b0a7
commit 8f6d30dd69
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")
}
}
}
}
}
}
}