From 8f6d30dd69fb2a5ff7bf445c4f806eb8faa476a8 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 14:15:10 +0100 Subject: [PATCH] feat: add Export/Import character as JSON (Closes #29) (#72) --- .../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") + } + } + } + } + } } }