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:
@@ -320,7 +320,12 @@ private fun MainScaffold(
|
||||
)
|
||||
}
|
||||
composable(AppRoutes.SETTINGS) {
|
||||
SettingsPage()
|
||||
SettingsPage(
|
||||
character = character,
|
||||
onImportCharacter = { imported ->
|
||||
characterViewModel.setCharacter(imported)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user