diff --git a/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt b/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt index 34fc93bf..56ab4486 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt @@ -117,21 +117,6 @@ fun ProvideAsyncButtonOperationIdentifierKey( ) } -fun normalizeAsyncButtonErrorMessage(raw: String?): String { - if (raw.isNullOrBlank()) return "Something went wrong" - val trimmed = raw.trim() - - val optionalQuoted = Regex("""Optional\("(.+)"\)""") - .find(trimmed)?.groupValues?.getOrNull(1) - if (!optionalQuoted.isNullOrBlank()) return optionalQuoted - - val optionalPlain = Regex("""Optional\((.+)\)""") - .find(trimmed)?.groupValues?.getOrNull(1) - if (!optionalPlain.isNullOrBlank()) return optionalPlain.trim().trim('"') - - return trimmed -} - /** * Shared async state holder used by Android button wrappers. * @@ -173,7 +158,7 @@ class AsyncButtonController internal constructor( fun rememberAsyncButtonController( action: suspend () -> Unit, errorMessageResolver: (Throwable?) -> String = { throwable -> - normalizeAsyncButtonErrorMessage(throwable?.localizedMessage ?: throwable?.message) + normalizeAsyncButtonErrorMessage(throwable) } ): AsyncButtonController { val loadingConfig = LocalAsyncButtonLoadingConfiguration.current @@ -283,7 +268,7 @@ fun BSWAsyncButton( disableTextColor: Color? = null, action: suspend () -> Unit, errorMessageResolver: (Throwable?) -> String = { throwable -> - normalizeAsyncButtonErrorMessage(throwable?.localizedMessage ?: throwable?.message) + normalizeAsyncButtonErrorMessage(throwable) }, progressView: @Composable (BSWAsyncButtonLoadingConfiguration.Style) -> Unit = { style -> DefaultAsyncButtonProgressView(style = style) diff --git a/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt b/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt index 2af07b80..101cdd70 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt @@ -208,15 +208,10 @@ fun BSWDefaultAsyncErrorView( } private fun Throwable.toDisplayMessage(): String { - val preferred = localizedMessage ?: message - val fallback = preferred ?: toString() - - val optionalRegex = Regex("""errorDescription:\s*Optional\("(.+)"\)""") - optionalRegex.find(fallback)?.groupValues?.getOrNull(1)?.let { extracted -> - if (extracted.isNotBlank()) return extracted - } - - return fallback + return extractAsyncButtonErrorMessage(this) + ?: localizedMessage + ?: message + ?: toString() } /** diff --git a/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt b/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt new file mode 100644 index 00000000..0e0bfa9e --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt @@ -0,0 +1,203 @@ +package bswinterface.kit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +sealed interface AsyncBlockingTaskConfirmationStrategy { + data object NotRequired : AsyncBlockingTaskConfirmationStrategy + + data class ConfirmWith( + val title: String, + val message: String, + val confirmButtonTitle: String, + val cancelButtonTitle: String, + val isDestructiveAction: Boolean = false + ) : AsyncBlockingTaskConfirmationStrategy +} + +internal data class BlockingTaskRequest( + val id: Long, + val input: Input +) + +@Stable +class BlockingTaskState internal constructor() { + private var requestID by mutableLongStateOf(0L) + + internal var pendingRequest by mutableStateOf?>(null) + internal var isRunning by mutableStateOf(false) + internal var errorText by mutableStateOf(null) + + fun trigger(input: Input) { + requestID += 1 + pendingRequest = BlockingTaskRequest(id = requestID, input = input) + } + + fun dismissError() { + errorText = null + } +} + +@Composable +fun rememberBlockingTaskState(): BlockingTaskState { + return remember { BlockingTaskState() } +} + +@Composable +fun PerformBlockingTask( + state: BlockingTaskState, + loadingTitle: String, + confirmationStrategy: AsyncBlockingTaskConfirmationStrategy = AsyncBlockingTaskConfirmationStrategy.NotRequired, + errorMessage: (Throwable?) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable) }, + task: suspend (Input) -> Unit, + content: @Composable () -> Unit +) { + PerformBlockingTaskHost( + state = state, + loadingTitle = loadingTitle, + confirmationStrategy = confirmationStrategy, + errorMessage = errorMessage, + task = task + ) + content() +} + +@Composable +fun PerformBlockingTaskHost( + state: BlockingTaskState, + loadingTitle: String, + confirmationStrategy: AsyncBlockingTaskConfirmationStrategy = AsyncBlockingTaskConfirmationStrategy.NotRequired, + errorMessage: (Throwable?) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable) }, + task: suspend (Input) -> Unit +) { + val scope = rememberCoroutineScope() + var confirmationRequest by remember(state) { mutableStateOf?>(null) } + + fun launchTask(request: BlockingTaskRequest) { + state.isRunning = true + state.errorText = null + scope.launch { + try { + task(request.input) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + state.errorText = errorMessage(throwable) + } finally { + state.isRunning = false + } + } + } + + LaunchedEffect(state.pendingRequest?.id, state.isRunning, confirmationStrategy) { + val request = state.pendingRequest ?: return@LaunchedEffect + if (state.isRunning) return@LaunchedEffect + + state.pendingRequest = null + when (confirmationStrategy) { + AsyncBlockingTaskConfirmationStrategy.NotRequired -> launchTask(request) + is AsyncBlockingTaskConfirmationStrategy.ConfirmWith -> confirmationRequest = request + } + } + + if (state.isRunning) { + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text( + text = loadingTitle, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } + } + } + + if (state.errorText != null) { + AlertDialog( + onDismissRequest = { state.dismissError() }, + confirmButton = { + TextButton(onClick = { state.dismissError() }) { + Text("OK") + } + }, + text = { + Text( + text = state.errorText.orEmpty(), + textAlign = TextAlign.Center + ) + } + ) + } + + val confirmation = confirmationRequest + if (confirmation != null && confirmationStrategy is AsyncBlockingTaskConfirmationStrategy.ConfirmWith) { + AlertDialog( + onDismissRequest = { confirmationRequest = null }, + title = { Text(confirmationStrategy.title) }, + text = { Text(confirmationStrategy.message) }, + confirmButton = { + TextButton( + onClick = { + confirmationRequest = null + launchTask(confirmation) + } + ) { + Text( + text = confirmationStrategy.confirmButtonTitle, + color = if (confirmationStrategy.isDestructiveAction) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + } + }, + dismissButton = { + TextButton(onClick = { confirmationRequest = null }) { + Text(confirmationStrategy.cancelButtonTitle) + } + } + ) + } +} diff --git a/Sources/BSWInterfaceKit/Skip/BSWErrorResolution.kt b/Sources/BSWInterfaceKit/Skip/BSWErrorResolution.kt new file mode 100644 index 00000000..eb8a15a4 --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/BSWErrorResolution.kt @@ -0,0 +1,176 @@ +package bswinterface.kit + +import org.json.JSONObject +import skip.foundation.LocalizedError +import skip.foundation.NSError +import skip.lib.aserror + +fun normalizeAsyncButtonErrorMessage(raw: String?): String { + return extractAsyncButtonErrorMessage(raw) ?: "Something went wrong" +} + +fun normalizeAsyncButtonErrorMessage(throwable: Throwable?): String { + return extractAsyncButtonErrorMessage(throwable) ?: "Something went wrong" +} + +fun extractAsyncButtonErrorMessage(throwable: Throwable?): String? { + if (throwable == null) return null + + return throwable.errorChain() + .flatMap { current -> current.messageCandidates().asSequence() } + .mapNotNull(::extractAsyncButtonErrorMessage) + .firstOrNull() +} + +private fun parseAsyncButtonErrorMessage(raw: String): String? { + parseAsyncButtonJsonMessage(raw)?.let { return it } + + val patterns = listOf( + Regex("errorDescription:\\s*Optional\\(\"(.+?)\"\\)"), + Regex("errorDescription:\\s*\"(.+?)\""), + Regex("\\\\\"message\\\\\"\\s*:\\s*\\\\\"(.+?)\\\\\""), + Regex("\"message\"\\s*:\\s*\"(.+?)\""), + Regex("Optional\\(\"(.+?)\"\\)"), + Regex("Optional\\((.+?)\\)") + ) + + return patterns + .firstNotNullOfOrNull { pattern -> + pattern.find(raw)?.groupValues?.getOrNull(1)?.trim()?.trim('"') + } + ?.let(::decodeEscapedMessage) + ?.takeIf { it.isNotBlank() } +} + +private fun parseAsyncButtonJsonMessage(raw: String): String? { + val candidates = buildList { + add(raw) + extractJsonObject(raw)?.let(::add) + unescapeJsonContainer(raw)?.let { unescaped -> + add(unescaped) + extractJsonObject(unescaped)?.let(::add) + } + } + + return candidates.firstNotNullOfOrNull { candidate -> + runCatching { + val jsonObject = JSONObject(candidate) + jsonObject.optString("message").takeIf { it.isNotBlank() } + ?: jsonObject.optJSONObject("error")?.optString("message")?.takeIf { it.isNotBlank() } + }.getOrNull() + }?.let(::decodeEscapedMessage) +} + +private fun extractJsonObject(raw: String): String? { + val startIndex = raw.indexOf('{') + val endIndex = raw.lastIndexOf('}') + if (startIndex == -1 || endIndex <= startIndex) return null + return raw.substring(startIndex, endIndex + 1) +} + +private fun unescapeJsonContainer(raw: String): String? { + if (!raw.contains("\\\"") && !raw.contains("\\u") && !raw.contains("\\n")) return null + + return raw + .replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") +} + +private fun decodeEscapedMessage(raw: String): String { + var decoded = raw + .replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\/", "/") + + Regex("""\\u([0-9a-fA-F]{4})""").findAll(decoded).forEach { match -> + val replacement = match.groupValues[1].toInt(16).toChar().toString() + decoded = decoded.replace(match.value, replacement) + } + + return decoded +} + +private fun Throwable.errorChain(): Sequence = sequence { + val visited = LinkedHashSet() + var current: Throwable? = this@errorChain + + while (current != null && visited.add(current)) { + yield(current) + current = current.cause + } +} + +private fun Throwable.messageCandidates(): List = buildList { + if (this@messageCandidates is LocalizedError) { + add(errorDescription) + add(failureReason) + add(recoverySuggestion) + } + + if (this@messageCandidates is NSError) { + add(localizedDescription) + add(localizedFailureReason) + add(localizedRecoverySuggestion) + } + + if (this@messageCandidates is skip.lib.Error) { + add(localizedDescription) + } + + add(aserror().localizedDescription) + add(localizedMessage) + add(message) + add(reflectiveString("errorDescription")) + add(reflectiveString("localizedDescription")) + add(reflectiveString("message")) + add(toString()) +} + +private fun Throwable.reflectiveString(propertyName: String): String? { + val getterName = buildString { + append("get") + append(propertyName.replaceFirstChar { char -> char.uppercase() }) + } + + return runCatching { + javaClass.methods + .firstOrNull { method -> + method.parameterCount == 0 && + (method.name == getterName || method.name == propertyName) && + method.returnType == String::class.java + } + ?.invoke(this) as? String + }.getOrNull() +} + +fun extractAsyncButtonErrorMessage(raw: String?): String? { + if (raw.isNullOrBlank()) return null + val trimmed = raw.trim() + val parsed = parseAsyncButtonErrorMessage(trimmed) ?: trimmed + + if (parsed.isBlank()) return null + if (parsed.isTechnicalPayload()) return null + if (parsed.isGenericSystemMessage()) return null + + return parsed +} + +private fun String.isTechnicalPayload(): Boolean { + val normalized = trim() + if (normalized.matches(Regex("""^\d+\s+bytes\)?$""", RegexOption.IGNORE_CASE))) return true + if (normalized.contains("failureStatusCode(", ignoreCase = true)) return true + if (normalized.matches(Regex("""Optional\(\d+\s+bytes\)""", RegexOption.IGNORE_CASE))) return true + if (normalized.startsWith("Error Domain=", ignoreCase = true)) return true + return false +} + +private fun String.isGenericSystemMessage(): Boolean { + val normalized = trim() + if (normalized.equals("Something went wrong", ignoreCase = true)) return true + return normalized.contains("operation could", ignoreCase = true) && + normalized.contains("be completed", ignoreCase = true) +}