From 0d00130e51dfae2079dbf878ed7716bf10fcf05b Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 14:14:53 +0100 Subject: [PATCH] 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 --- .../kotlin/org/shahondin1624/App.kt | 7 +- .../shahondin1624/lib/components/TestTags.kt | 14 +++ .../settings/ExportCharacterDialog.kt | 65 ++++++++++++ .../settings/ImportCharacterDialog.kt | 87 ++++++++++++++++ .../lib/components/settings/Settings.kt | 98 ++++++++++++++++++- 5 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ExportCharacterDialog.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ImportCharacterDialog.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 53d4a8b..6c29b12 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -320,7 +320,12 @@ private fun MainScaffold( ) } composable(AppRoutes.SETTINGS) { - SettingsPage() + SettingsPage( + character = character, + onImportCharacter = { imported -> + characterViewModel.setCharacter(imported) + } + ) } } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index b341fc0..58f87eb 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -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" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ExportCharacterDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ExportCharacterDialog.kt new file mode 100644 index 0000000..0b7d04d --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ExportCharacterDialog.kt @@ -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") + } + } + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ImportCharacterDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ImportCharacterDialog.kt new file mode 100644 index 0000000..ad59c40 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/ImportCharacterDialog.kt @@ -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(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") + } + } + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt index f40964b..c1e86d6 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt @@ -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") + } + } + } + } + } } } -- 2.49.1