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)
+}