diff --git a/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt b/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt
index 0e0bfa9e..3c6d3918 100644
--- a/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt
+++ b/Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt
@@ -1,16 +1,27 @@
package bswinterface.kit
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -19,13 +30,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
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.delay
import kotlinx.coroutines.launch
sealed interface AsyncBlockingTaskConfirmationStrategy {
@@ -50,17 +64,11 @@ 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
@@ -68,55 +76,133 @@ fun rememberBlockingTaskState(): BlockingTaskState {
return remember { BlockingTaskState() }
}
+private val LocalBlockingTaskError =
+ staticCompositionLocalOf { null }
+
+@Composable
+fun currentBlockingTaskError(): Throwable? = LocalBlockingTaskError.current
+
+private sealed interface BlockingTaskHudState {
+ data object None : BlockingTaskHudState
+ data class Loading(val title: String) : BlockingTaskHudState
+ data class Success(val message: String?) : BlockingTaskHudState
+ data class Error(val message: String) : BlockingTaskHudState
+}
+
+private enum class BlockingTaskHudKind {
+ Loading,
+ Success,
+ Error
+}
+
@Composable
fun PerformBlockingTask(
state: BlockingTaskState,
loadingTitle: String,
+ successMessage: String? = null,
+ successDisplayMillis: Long = 1000L,
confirmationStrategy: AsyncBlockingTaskConfirmationStrategy = AsyncBlockingTaskConfirmationStrategy.NotRequired,
errorMessage: (Throwable?) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable) },
+ errorDisplayMillis: Long = 1500L,
+ scrimAlpha: Float = 0.35f,
task: suspend (Input) -> Unit,
content: @Composable () -> Unit
) {
- PerformBlockingTaskHost(
+ val lastError = rememberBlockingTaskPresenter(
state = state,
loadingTitle = loadingTitle,
+ successMessage = successMessage,
+ successDisplayMillis = successDisplayMillis,
confirmationStrategy = confirmationStrategy,
errorMessage = errorMessage,
+ errorDisplayMillis = errorDisplayMillis,
+ scrimAlpha = scrimAlpha,
task = task
)
- content()
+
+ CompositionLocalProvider(LocalBlockingTaskError provides lastError) {
+ content()
+ }
}
@Composable
fun PerformBlockingTaskHost(
state: BlockingTaskState,
loadingTitle: String,
+ successMessage: String? = null,
+ successDisplayMillis: Long = 1000L,
confirmationStrategy: AsyncBlockingTaskConfirmationStrategy = AsyncBlockingTaskConfirmationStrategy.NotRequired,
errorMessage: (Throwable?) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable) },
+ errorDisplayMillis: Long = 1500L,
+ scrimAlpha: Float = 0.35f,
task: suspend (Input) -> Unit
) {
+ rememberBlockingTaskPresenter(
+ state = state,
+ loadingTitle = loadingTitle,
+ successMessage = successMessage,
+ successDisplayMillis = successDisplayMillis,
+ confirmationStrategy = confirmationStrategy,
+ errorMessage = errorMessage,
+ errorDisplayMillis = errorDisplayMillis,
+ scrimAlpha = scrimAlpha,
+ task = task
+ )
+}
+
+@Composable
+private fun rememberBlockingTaskPresenter(
+ state: BlockingTaskState,
+ loadingTitle: String,
+ successMessage: String?,
+ successDisplayMillis: Long,
+ confirmationStrategy: AsyncBlockingTaskConfirmationStrategy,
+ errorMessage: (Throwable?) -> String,
+ errorDisplayMillis: Long,
+ scrimAlpha: Float,
+ task: suspend (Input) -> Unit
+): Throwable? {
val scope = rememberCoroutineScope()
+ var hudState by remember(state) { mutableStateOf(BlockingTaskHudState.None) }
var confirmationRequest by remember(state) { mutableStateOf?>(null) }
+ var isRunning by remember(state) { mutableStateOf(false) }
+ var lastError by remember(state) { mutableStateOf(null) }
fun launchTask(request: BlockingTaskRequest) {
- state.isRunning = true
- state.errorText = null
+ isRunning = true
+ lastError = null
+ hudState = BlockingTaskHudState.Loading(loadingTitle)
+
scope.launch {
try {
task(request.input)
+
+ if (!successMessage.isNullOrBlank()) {
+ hudState = BlockingTaskHudState.Success(successMessage)
+ delay(successDisplayMillis)
+ }
} catch (cancellationException: CancellationException) {
+ hudState = BlockingTaskHudState.None
throw cancellationException
} catch (throwable: Throwable) {
- state.errorText = errorMessage(throwable)
+ lastError = throwable
+ val resolvedMessage = errorMessage(throwable)
+ .takeUnless { it.isNullOrBlank() }
+ ?: normalizeAsyncButtonErrorMessage(throwable)
+
+ hudState = BlockingTaskHudState.Error(resolvedMessage)
+ delay(errorDisplayMillis)
+ lastError = null
} finally {
- state.isRunning = false
+ hudState = BlockingTaskHudState.None
+ isRunning = false
}
}
}
- LaunchedEffect(state.pendingRequest?.id, state.isRunning, confirmationStrategy) {
+ LaunchedEffect(state.pendingRequest?.id, isRunning, confirmationStrategy) {
val request = state.pendingRequest ?: return@LaunchedEffect
- if (state.isRunning) return@LaunchedEffect
+ if (isRunning) return@LaunchedEffect
state.pendingRequest = null
when (confirmationStrategy) {
@@ -125,48 +211,27 @@ fun PerformBlockingTaskHost(
}
}
- 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
- )
- }
- }
- }
- }
+ when (val currentHudState = hudState) {
+ BlockingTaskHudState.None -> Unit
+ is BlockingTaskHudState.Loading -> BlockingTaskHudDialog(
+ visible = true,
+ text = currentHudState.title,
+ kind = BlockingTaskHudKind.Loading,
+ scrimAlpha = scrimAlpha
+ )
- if (state.errorText != null) {
- AlertDialog(
- onDismissRequest = { state.dismissError() },
- confirmButton = {
- TextButton(onClick = { state.dismissError() }) {
- Text("OK")
- }
- },
- text = {
- Text(
- text = state.errorText.orEmpty(),
- textAlign = TextAlign.Center
- )
- }
+ is BlockingTaskHudState.Success -> BlockingTaskHudDialog(
+ visible = true,
+ text = currentHudState.message,
+ kind = BlockingTaskHudKind.Success,
+ scrimAlpha = scrimAlpha
+ )
+
+ is BlockingTaskHudState.Error -> BlockingTaskHudDialog(
+ visible = true,
+ text = currentHudState.message,
+ kind = BlockingTaskHudKind.Error,
+ scrimAlpha = scrimAlpha
)
}
@@ -200,4 +265,78 @@ fun PerformBlockingTaskHost(
}
)
}
+
+ return lastError
+}
+
+@Composable
+private fun BlockingTaskHudDialog(
+ visible: Boolean,
+ text: String?,
+ kind: BlockingTaskHudKind,
+ scrimAlpha: Float
+) {
+ if (!visible) return
+
+ Dialog(
+ onDismissRequest = {},
+ properties = DialogProperties(
+ dismissOnBackPress = false,
+ dismissOnClickOutside = false,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = scrimAlpha))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {}
+ ) {
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ tonalElevation = 8.dp,
+ shadowElevation = 8.dp,
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(horizontal = 32.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ when (kind) {
+ BlockingTaskHudKind.Loading -> CircularProgressIndicator(modifier = Modifier.size(32.dp))
+ BlockingTaskHudKind.Success -> Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(32.dp)
+ )
+
+ BlockingTaskHudKind.Error -> Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+
+ text?.takeIf { it.isNotBlank() }?.let {
+ Text(
+ text = it,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ )
+ }
+ }
+ }
+ }
+ }
}