Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 194 additions & 55 deletions Sources/BSWInterfaceKit/Skip/BSWBlockingTask.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -50,73 +64,145 @@ class BlockingTaskState<Input> internal constructor() {
private var requestID by mutableLongStateOf(0L)

internal var pendingRequest by mutableStateOf<BlockingTaskRequest<Input>?>(null)
internal var isRunning by mutableStateOf(false)
internal var errorText by mutableStateOf<String?>(null)

fun trigger(input: Input) {
requestID += 1
pendingRequest = BlockingTaskRequest(id = requestID, input = input)
}

fun dismissError() {
errorText = null
}
}

@Composable
fun <Input> rememberBlockingTaskState(): BlockingTaskState<Input> {
return remember { BlockingTaskState() }
}

private val LocalBlockingTaskError =
staticCompositionLocalOf<Throwable?> { 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 <Input> PerformBlockingTask(
state: BlockingTaskState<Input>,
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 <Input> PerformBlockingTaskHost(
state: BlockingTaskState<Input>,
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 <Input> rememberBlockingTaskPresenter(
state: BlockingTaskState<Input>,
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>(BlockingTaskHudState.None) }
var confirmationRequest by remember(state) { mutableStateOf<BlockingTaskRequest<Input>?>(null) }
var isRunning by remember(state) { mutableStateOf(false) }
var lastError by remember(state) { mutableStateOf<Throwable?>(null) }

fun launchTask(request: BlockingTaskRequest<Input>) {
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) {
Expand All @@ -125,48 +211,27 @@ fun <Input> 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
)
}

Expand Down Expand Up @@ -200,4 +265,78 @@ fun <Input> 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
)
)
}
}
}
}
}
}
Loading