diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 732aedb..a7eed59 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -35,6 +35,7 @@ import org.shahondin1624.lib.components.TestTags import org.shahondin1624.lib.components.UiConstants import org.shahondin1624.lib.components.charactermodel.CharacterSheetPage import org.shahondin1624.lib.components.charactermodel.DiceRollHistoryDialog +import org.shahondin1624.lib.components.charactermodel.LoadErrorDialog import org.shahondin1624.lib.components.charactermodel.SaveStatusIndicator import org.shahondin1624.lib.components.creation.CharacterCreationWizard import org.shahondin1624.lib.functions.DiceRollHistory @@ -49,6 +50,7 @@ import org.shahondin1624.theme.WindowSizeClass import org.shahondin1624.viewmodel.CharacterCreationViewModel import org.shahondin1624.logging.AppLogger import org.shahondin1624.viewmodel.CharacterViewModel +import org.shahondin1624.viewmodel.LoadStatus import org.shahondin1624.viewmodel.SaveStatus @OptIn(ExperimentalMaterial3Api::class) @@ -278,8 +280,19 @@ private fun MainScaffold( val currentRoute = currentRoute(navController) val character by characterViewModel.character.collectAsState() val saveStatus by characterViewModel.saveStatus.collectAsState() + val loadStatus by characterViewModel.loadStatus.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + // Show load error dialog when deserialization fails + if (loadStatus is LoadStatus.Error) { + val error = loadStatus as LoadStatus.Error + LoadErrorDialog( + errorMessage = error.message, + rawJson = error.rawJson, + onStartFresh = { characterViewModel.acknowledgeLoadError() } + ) + } + // Show snackbar when a save error occurs LaunchedEffect(saveStatus) { if (saveStatus is SaveStatus.Error) { 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 fd3916c..e4cfb08 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -186,6 +186,13 @@ object TestTags { const val HEALING_DISMISS_BUTTON = "healing_dismiss_button" fun healButton(track: String): String = "heal_button_${track.lowercase()}" + // --- Load error dialog --- + const val LOAD_ERROR_DIALOG = "load_error_dialog" + const val LOAD_ERROR_MESSAGE = "load_error_message" + const val LOAD_ERROR_START_FRESH = "load_error_start_fresh" + const val LOAD_ERROR_SHOW_RAW = "load_error_show_raw" + const val LOAD_ERROR_RAW_JSON = "load_error_raw_json" + // --- Recovery state --- const val RECOVERY_STATE_BADGE = "recovery_state_badge" const val DEATH_WARNING = "death_warning" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/LoadErrorDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/LoadErrorDialog.kt new file mode 100644 index 0000000..df1f305 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/LoadErrorDialog.kt @@ -0,0 +1,121 @@ +package org.shahondin1624.lib.components.charactermodel + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 shown when the saved character data could not be deserialized. + * Gives the user two options: + * 1. Start Fresh — accept EXAMPLE_CHARACTER as the current character + * 2. Show Raw Data — display the raw JSON for manual recovery / export + * + * @param errorMessage A description of what went wrong during deserialization. + * @param rawJson The raw JSON string that failed to deserialize, preserved for recovery. + * @param onStartFresh Called when the user chooses to start fresh with a default character. + * @param onDismiss Called when the dialog is dismissed (same as start fresh, since we must proceed). + */ +@Composable +fun LoadErrorDialog( + errorMessage: String, + rawJson: String, + onStartFresh: () -> Unit, + onDismiss: () -> Unit = onStartFresh +) { + var showRawJson by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.LOAD_ERROR_DIALOG), + title = { + Text("Failed to Load Character") + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Your saved character data could not be loaded. " + + "This may be due to corrupted data or an incompatible format change.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Error: $errorMessage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(TestTags.LOAD_ERROR_MESSAGE) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Your original data has been backed up and is shown below " + + "so you can copy it for manual recovery.", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(8.dp)) + + if (!showRawJson) { + OutlinedButton( + onClick = { showRawJson = true }, + modifier = Modifier.testTag(TestTags.LOAD_ERROR_SHOW_RAW) + ) { + Text("Show Raw Data") + } + } else { + Text( + text = "Raw JSON:", + style = MaterialTheme.typography.labelMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + ) { + Text( + text = rawJson.ifEmpty { "(empty)" }, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + modifier = Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + .testTag(TestTags.LOAD_ERROR_RAW_JSON) + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = onStartFresh, + modifier = Modifier.testTag(TestTags.LOAD_ERROR_START_FRESH) + ) { + Text("Start Fresh") + } + } + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/CharacterViewModel.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/CharacterViewModel.kt index 5de4918..8571b06 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/CharacterViewModel.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/CharacterViewModel.kt @@ -26,11 +26,16 @@ class CharacterViewModel : ViewModel() { private val settings = Settings() + private val _loadStatus = MutableStateFlow(LoadStatus.Success) + private val _character = MutableStateFlow(loadCharacter()) /** Observable character state. */ val character: StateFlow = _character.asStateFlow() + /** Observable load status — UI should check for [LoadStatus.Error] to show recovery dialog. */ + val loadStatus: StateFlow = _loadStatus.asStateFlow() + private val _saveStatus = MutableStateFlow(SaveStatus.Idle) /** Observable save status for UI display. */ @@ -75,8 +80,37 @@ class CharacterViewModel : ViewModel() { saveCharacter(_character.value) } + /** + * Acknowledge a load error and clear the error state. + * Called after the user has made a choice in the error dialog (e.g., "Start Fresh"). + */ + fun acknowledgeLoadError() { + _loadStatus.value = LoadStatus.Success + } + + /** + * Attempt to deserialize a raw JSON string as a character. + * Used for manual recovery when the user edits or re-pastes corrupted data. + * + * @return the deserialized character, or null if it still fails + */ + fun tryRecoverFromJson(json: String): ShadowrunCharacter? { + return try { + val character = DataLoader.deserialize(json) + Napier.i(tag = TAG) { "Recovery successful: ${character.characterData.name}" } + _character.value = character + _loadStatus.value = LoadStatus.Success + character + } catch (e: Exception) { + Napier.e(tag = TAG, throwable = e) { "Recovery attempt failed" } + null + } + } + /** * Load character from local storage, falling back to EXAMPLE_CHARACTER. + * On deserialization failure, the raw JSON is preserved in a backup key + * and the error is reported via [_loadStatus]. */ private fun loadCharacter(): ShadowrunCharacter { return try { @@ -84,15 +118,27 @@ class CharacterViewModel : ViewModel() { if (json != null) { val character = DataLoader.deserialize(json) Napier.i(tag = TAG) { "Character loaded: ${character.characterData.name}" } + _loadStatus.value = LoadStatus.Success character } else { Napier.i(tag = TAG) { "No saved character found, using example character" } + _loadStatus.value = LoadStatus.NoSavedData EXAMPLE_CHARACTER } } catch (e: Exception) { + val rawJson = settings.getStringOrNull(STORAGE_KEY) ?: "" Napier.e(tag = TAG, throwable = e) { "Failed to deserialize saved character, falling back to example character" } + // Preserve the raw data so it is not lost + if (rawJson.isNotEmpty()) { + settings.putString(BACKUP_KEY, rawJson) + Napier.i(tag = TAG) { "Raw character data backed up under key '$BACKUP_KEY'" } + } + _loadStatus.value = LoadStatus.Error( + message = e.message ?: "Unknown deserialization error", + rawJson = rawJson + ) EXAMPLE_CHARACTER } } @@ -143,6 +189,8 @@ class CharacterViewModel : ViewModel() { companion object { private const val TAG = "CharacterViewModel" private const val STORAGE_KEY = "shadowrun_character" + /** Backup key for preserving raw JSON when deserialization fails. */ + private const val BACKUP_KEY = "shadowrun_character_backup" /** How long to display the "Saved" status before reverting to Idle. */ private const val SAVED_DISPLAY_DURATION_MS = 2000L } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/LoadStatus.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/LoadStatus.kt new file mode 100644 index 0000000..d30de1e --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/LoadStatus.kt @@ -0,0 +1,20 @@ +package org.shahondin1624.viewmodel + +/** + * Represents the result of loading a character from local storage. + * Exposed by [CharacterViewModel] so the UI can display load errors + * and offer recovery options. + */ +sealed class LoadStatus { + /** Character loaded successfully from storage. */ + data object Success : LoadStatus() + + /** No saved data found — using default character. */ + data object NoSavedData : LoadStatus() + + /** Deserialization failed. Raw JSON is preserved for recovery. */ + data class Error( + val message: String, + val rawJson: String + ) : LoadStatus() +} diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/CharacterViewModelTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/CharacterViewModelTest.kt index 27c6598..d8bddd7 100644 --- a/sharedUI/src/commonTest/kotlin/org/shahondin1624/CharacterViewModelTest.kt +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/CharacterViewModelTest.kt @@ -8,9 +8,11 @@ import org.shahondin1624.model.characterdata.CharacterData import org.shahondin1624.model.characterdata.Metatype import org.shahondin1624.model.charactermodel.ShadowrunCharacter import org.shahondin1624.model.createNewCharacter +import org.shahondin1624.viewmodel.LoadStatus import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -205,6 +207,49 @@ class CharacterViewModelTest { assertEquals(50, restored.characterData.totalKarma) } + // ---- LoadStatus model ---- + + @Test + fun loadStatusErrorPreservesRawJson() { + val rawJson = """{"characterData": {"name": "Corrupted"}}""" + val status = LoadStatus.Error( + message = "Missing required field", + rawJson = rawJson + ) + assertIs(status) + assertEquals("Missing required field", status.message) + assertEquals(rawJson, status.rawJson) + } + + @Test + fun loadStatusSuccessAndNoSavedDataAreSingletons() { + val s1 = LoadStatus.Success + val s2 = LoadStatus.Success + assertEquals(s1, s2) + + val n1 = LoadStatus.NoSavedData + val n2 = LoadStatus.NoSavedData + assertEquals(n1, n2) + } + + @Test + fun deserializeMalformedJsonPreservesDataForRecovery() { + // Simulate what the ViewModel does: if deserialization fails, + // the raw JSON should be capturable for backup + val corruptedJson = """{"characterData": "not_an_object"}""" + var capturedRawJson: String? = null + + try { + DataLoader.deserialize(corruptedJson) + } catch (_: Exception) { + // In the ViewModel, this is where we'd preserve the raw JSON + capturedRawJson = corruptedJson + } + + assertNotNull(capturedRawJson, "Raw JSON should be preserved on failure") + assertEquals(corruptedJson, capturedRawJson) + } + @Test fun saveAndLoadNotesAndLifestyles() { val modified = EXAMPLE_CHARACTER.copy( diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/LoadErrorDialogTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/LoadErrorDialogTest.kt new file mode 100644 index 0000000..583c7ee --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/LoadErrorDialogTest.kt @@ -0,0 +1,110 @@ +package org.shahondin1624 + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.lib.components.charactermodel.LoadErrorDialog +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Tests for the LoadErrorDialog composable. + */ +@OptIn(ExperimentalTestApi::class) +class LoadErrorDialogTest { + + @Test + fun dialogDisplaysErrorMessage() = runComposeUiTest { + setContent { + LoadErrorDialog( + errorMessage = "Unexpected JSON token", + rawJson = """{"broken": true}""", + onStartFresh = {} + ) + } + + onNodeWithTag(TestTags.LOAD_ERROR_DIALOG).assertIsDisplayed() + onNodeWithTag(TestTags.LOAD_ERROR_MESSAGE).assertIsDisplayed() + onNodeWithTag(TestTags.LOAD_ERROR_MESSAGE) + .assertTextContains("Unexpected JSON token", substring = true) + } + + @Test + fun startFreshButtonCallsCallback() = runComposeUiTest { + var startFreshCalled = false + + setContent { + LoadErrorDialog( + errorMessage = "Test error", + rawJson = """{"test": 1}""", + onStartFresh = { startFreshCalled = true } + ) + } + + onNodeWithTag(TestTags.LOAD_ERROR_START_FRESH).assertIsDisplayed() + onNodeWithTag(TestTags.LOAD_ERROR_START_FRESH).performClick() + assertTrue(startFreshCalled, "Start Fresh callback should have been called") + } + + @Test + fun showRawDataButtonRevealsJson() = runComposeUiTest { + val rawJson = """{"characterData": {"name": "Lost Runner"}}""" + + setContent { + LoadErrorDialog( + errorMessage = "Parse error", + rawJson = rawJson, + onStartFresh = {} + ) + } + + // Raw JSON should not be visible initially + onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON).assertDoesNotExist() + + // Click "Show Raw Data" + onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).assertIsDisplayed() + onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).performClick() + + // Now raw JSON should be visible + onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON).assertIsDisplayed() + onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON) + .assertTextContains("Lost Runner", substring = true) + } + + @Test + fun showRawDataButtonHidesAfterClick() = runComposeUiTest { + setContent { + LoadErrorDialog( + errorMessage = "Error", + rawJson = "{}", + onStartFresh = {} + ) + } + + onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).assertIsDisplayed() + onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).performClick() + + // After clicking, the button should be replaced by the raw JSON display + onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).assertDoesNotExist() + onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON).assertIsDisplayed() + } + + @Test + fun emptyRawJsonShowsPlaceholder() = runComposeUiTest { + setContent { + LoadErrorDialog( + errorMessage = "Error", + rawJson = "", + onStartFresh = {} + ) + } + + onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).performClick() + onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON) + .assertTextContains("(empty)", substring = true) + } +}