feat: add Export/Import character as JSON (Closes #29) #72
@@ -320,7 +320,12 @@ private fun MainScaffold(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(AppRoutes.SETTINGS) {
|
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_SYSTEM = "settings_theme_system"
|
||||||
const val SETTINGS_THEME_LIGHT = "settings_theme_light"
|
const val SETTINGS_THEME_LIGHT = "settings_theme_light"
|
||||||
const val SETTINGS_THEME_DARK = "settings_theme_dark"
|
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 ---
|
// --- Top app bar ---
|
||||||
const val TOP_APP_BAR = "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
|
package org.shahondin1624.lib.components.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
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.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.shahondin1624.lib.components.TestTags
|
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.LocalThemePreference
|
||||||
import org.shahondin1624.theme.ThemePreference
|
import org.shahondin1624.theme.ThemePreference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings page with theme selection (Light / Dark / System Default).
|
* Settings page with theme selection and character export/import.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsPage() {
|
fun SettingsPage(
|
||||||
|
character: ShadowrunCharacter? = null,
|
||||||
|
onImportCharacter: ((ShadowrunCharacter) -> Unit)? = null
|
||||||
|
) {
|
||||||
var themePreference by LocalThemePreference.current
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.testTag(TestTags.SETTINGS_PAGE),
|
.testTag(TestTags.SETTINGS_PAGE),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
// --- Appearance section ---
|
||||||
Text(
|
Text(
|
||||||
text = "Appearance",
|
text = "Appearance",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
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