11package bswinterface.kit
22
3+ import androidx.compose.foundation.background
4+ import androidx.compose.foundation.clickable
5+ import androidx.compose.foundation.interaction.MutableInteractionSource
36import androidx.compose.foundation.layout.Arrangement
7+ import androidx.compose.foundation.layout.Box
48import androidx.compose.foundation.layout.Column
9+ import androidx.compose.foundation.layout.fillMaxSize
510import androidx.compose.foundation.layout.padding
11+ import androidx.compose.foundation.layout.size
612import androidx.compose.foundation.shape.RoundedCornerShape
13+ import androidx.compose.material.icons.Icons
14+ import androidx.compose.material.icons.filled.Check
15+ import androidx.compose.material.icons.filled.Close
716import androidx.compose.material3.AlertDialog
817import androidx.compose.material3.CircularProgressIndicator
18+ import androidx.compose.material3.Icon
919import androidx.compose.material3.MaterialTheme
1020import androidx.compose.material3.Surface
1121import androidx.compose.material3.Text
1222import androidx.compose.material3.TextButton
1323import androidx.compose.runtime.Composable
24+ import androidx.compose.runtime.CompositionLocalProvider
1425import androidx.compose.runtime.LaunchedEffect
1526import androidx.compose.runtime.Stable
1627import androidx.compose.runtime.getValue
@@ -19,13 +30,16 @@ import androidx.compose.runtime.mutableStateOf
1930import androidx.compose.runtime.remember
2031import androidx.compose.runtime.rememberCoroutineScope
2132import androidx.compose.runtime.setValue
33+ import androidx.compose.runtime.staticCompositionLocalOf
2234import androidx.compose.ui.Alignment
2335import androidx.compose.ui.Modifier
36+ import androidx.compose.ui.graphics.Color
2437import androidx.compose.ui.text.style.TextAlign
2538import androidx.compose.ui.unit.dp
2639import androidx.compose.ui.window.Dialog
2740import androidx.compose.ui.window.DialogProperties
2841import kotlinx.coroutines.CancellationException
42+ import kotlinx.coroutines.delay
2943import kotlinx.coroutines.launch
3044
3145sealed interface AsyncBlockingTaskConfirmationStrategy {
@@ -50,73 +64,145 @@ class BlockingTaskState<Input> internal constructor() {
5064 private var requestID by mutableLongStateOf(0L )
5165
5266 internal var pendingRequest by mutableStateOf<BlockingTaskRequest <Input >? > (null )
53- internal var isRunning by mutableStateOf(false )
54- internal var errorText by mutableStateOf<String ?>(null )
5567
5668 fun trigger (input : Input ) {
5769 requestID + = 1
5870 pendingRequest = BlockingTaskRequest (id = requestID, input = input)
5971 }
60-
61- fun dismissError () {
62- errorText = null
63- }
6472}
6573
6674@Composable
6775fun <Input > rememberBlockingTaskState (): BlockingTaskState <Input > {
6876 return remember { BlockingTaskState () }
6977}
7078
79+ private val LocalBlockingTaskError =
80+ staticCompositionLocalOf<Throwable ?> { null }
81+
82+ @Composable
83+ fun currentBlockingTaskError (): Throwable ? = LocalBlockingTaskError .current
84+
85+ private sealed interface BlockingTaskHudState {
86+ data object None : BlockingTaskHudState
87+ data class Loading (val title : String ) : BlockingTaskHudState
88+ data class Success (val message : String? ) : BlockingTaskHudState
89+ data class Error (val message : String ) : BlockingTaskHudState
90+ }
91+
92+ private enum class BlockingTaskHudKind {
93+ Loading ,
94+ Success ,
95+ Error
96+ }
97+
7198@Composable
7299fun <Input > PerformBlockingTask (
73100 state : BlockingTaskState <Input >,
74101 loadingTitle : String ,
102+ successMessage : String? = null,
103+ successDisplayMillis : Long = 1000L,
75104 confirmationStrategy : AsyncBlockingTaskConfirmationStrategy = AsyncBlockingTaskConfirmationStrategy .NotRequired ,
76105 errorMessage : (Throwable ? ) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable) },
106+ errorDisplayMillis : Long = 1500L,
107+ scrimAlpha : Float = 0.35f,
77108 task : suspend (Input ) -> Unit ,
78109 content : @Composable () -> Unit
79110) {
80- PerformBlockingTaskHost (
111+ val lastError = rememberBlockingTaskPresenter (
81112 state = state,
82113 loadingTitle = loadingTitle,
114+ successMessage = successMessage,
115+ successDisplayMillis = successDisplayMillis,
83116 confirmationStrategy = confirmationStrategy,
84117 errorMessage = errorMessage,
118+ errorDisplayMillis = errorDisplayMillis,
119+ scrimAlpha = scrimAlpha,
85120 task = task
86121 )
87- content()
122+
123+ CompositionLocalProvider (LocalBlockingTaskError provides lastError) {
124+ content()
125+ }
88126}
89127
90128@Composable
91129fun <Input > PerformBlockingTaskHost (
92130 state : BlockingTaskState <Input >,
93131 loadingTitle : String ,
132+ successMessage : String? = null,
133+ successDisplayMillis : Long = 1000L,
94134 confirmationStrategy : AsyncBlockingTaskConfirmationStrategy = AsyncBlockingTaskConfirmationStrategy .NotRequired ,
95135 errorMessage : (Throwable ? ) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable) },
136+ errorDisplayMillis : Long = 1500L,
137+ scrimAlpha : Float = 0.35f,
96138 task : suspend (Input ) -> Unit
97139) {
140+ rememberBlockingTaskPresenter(
141+ state = state,
142+ loadingTitle = loadingTitle,
143+ successMessage = successMessage,
144+ successDisplayMillis = successDisplayMillis,
145+ confirmationStrategy = confirmationStrategy,
146+ errorMessage = errorMessage,
147+ errorDisplayMillis = errorDisplayMillis,
148+ scrimAlpha = scrimAlpha,
149+ task = task
150+ )
151+ }
152+
153+ @Composable
154+ private fun <Input > rememberBlockingTaskPresenter (
155+ state : BlockingTaskState <Input >,
156+ loadingTitle : String ,
157+ successMessage : String? ,
158+ successDisplayMillis : Long ,
159+ confirmationStrategy : AsyncBlockingTaskConfirmationStrategy ,
160+ errorMessage : (Throwable ? ) -> String ,
161+ errorDisplayMillis : Long ,
162+ scrimAlpha : Float ,
163+ task : suspend (Input ) -> Unit
164+ ): Throwable ? {
98165 val scope = rememberCoroutineScope()
166+ var hudState by remember(state) { mutableStateOf<BlockingTaskHudState >(BlockingTaskHudState .None ) }
99167 var confirmationRequest by remember(state) { mutableStateOf<BlockingTaskRequest <Input >? > (null ) }
168+ var isRunning by remember(state) { mutableStateOf(false ) }
169+ var lastError by remember(state) { mutableStateOf<Throwable ?>(null ) }
100170
101171 fun launchTask (request : BlockingTaskRequest <Input >) {
102- state.isRunning = true
103- state.errorText = null
172+ isRunning = true
173+ lastError = null
174+ hudState = BlockingTaskHudState .Loading (loadingTitle)
175+
104176 scope.launch {
105177 try {
106178 task(request.input)
179+
180+ if (! successMessage.isNullOrBlank()) {
181+ hudState = BlockingTaskHudState .Success (successMessage)
182+ delay(successDisplayMillis)
183+ }
107184 } catch (cancellationException: CancellationException ) {
185+ hudState = BlockingTaskHudState .None
108186 throw cancellationException
109187 } catch (throwable: Throwable ) {
110- state.errorText = errorMessage(throwable)
188+ lastError = throwable
189+ val resolvedMessage = errorMessage(throwable)
190+ .takeUnless { it.isNullOrBlank() }
191+ ? : normalizeAsyncButtonErrorMessage(throwable)
192+
193+ hudState = BlockingTaskHudState .Error (resolvedMessage)
194+ delay(errorDisplayMillis)
195+ lastError = null
111196 } finally {
112- state.isRunning = false
197+ hudState = BlockingTaskHudState .None
198+ isRunning = false
113199 }
114200 }
115201 }
116202
117- LaunchedEffect (state.pendingRequest?.id, state. isRunning, confirmationStrategy) {
203+ LaunchedEffect (state.pendingRequest?.id, isRunning, confirmationStrategy) {
118204 val request = state.pendingRequest ? : return @LaunchedEffect
119- if (state. isRunning) return @LaunchedEffect
205+ if (isRunning) return @LaunchedEffect
120206
121207 state.pendingRequest = null
122208 when (confirmationStrategy) {
@@ -125,48 +211,27 @@ fun <Input> PerformBlockingTaskHost(
125211 }
126212 }
127213
128- if (state.isRunning) {
129- Dialog (
130- onDismissRequest = {},
131- properties = DialogProperties (
132- dismissOnBackPress = false ,
133- dismissOnClickOutside = false
134- )
135- ) {
136- Surface (
137- shape = RoundedCornerShape (20 .dp),
138- color = MaterialTheme .colorScheme.surface
139- ) {
140- Column (
141- modifier = Modifier .padding(horizontal = 28 .dp, vertical = 24 .dp),
142- horizontalAlignment = Alignment .CenterHorizontally ,
143- verticalArrangement = Arrangement .spacedBy(16 .dp)
144- ) {
145- CircularProgressIndicator ()
146- Text (
147- text = loadingTitle,
148- style = MaterialTheme .typography.bodyMedium,
149- textAlign = TextAlign .Center
150- )
151- }
152- }
153- }
154- }
214+ when (val currentHudState = hudState) {
215+ BlockingTaskHudState .None -> Unit
216+ is BlockingTaskHudState .Loading -> BlockingTaskHudDialog (
217+ visible = true ,
218+ text = currentHudState.title,
219+ kind = BlockingTaskHudKind .Loading ,
220+ scrimAlpha = scrimAlpha
221+ )
155222
156- if (state.errorText != null ) {
157- AlertDialog (
158- onDismissRequest = { state.dismissError() },
159- confirmButton = {
160- TextButton (onClick = { state.dismissError() }) {
161- Text (" OK" )
162- }
163- },
164- text = {
165- Text (
166- text = state.errorText.orEmpty(),
167- textAlign = TextAlign .Center
168- )
169- }
223+ is BlockingTaskHudState .Success -> BlockingTaskHudDialog (
224+ visible = true ,
225+ text = currentHudState.message,
226+ kind = BlockingTaskHudKind .Success ,
227+ scrimAlpha = scrimAlpha
228+ )
229+
230+ is BlockingTaskHudState .Error -> BlockingTaskHudDialog (
231+ visible = true ,
232+ text = currentHudState.message,
233+ kind = BlockingTaskHudKind .Error ,
234+ scrimAlpha = scrimAlpha
170235 )
171236 }
172237
@@ -200,4 +265,78 @@ fun <Input> PerformBlockingTaskHost(
200265 }
201266 )
202267 }
268+
269+ return lastError
270+ }
271+
272+ @Composable
273+ private fun BlockingTaskHudDialog (
274+ visible : Boolean ,
275+ text : String? ,
276+ kind : BlockingTaskHudKind ,
277+ scrimAlpha : Float
278+ ) {
279+ if (! visible) return
280+
281+ Dialog (
282+ onDismissRequest = {},
283+ properties = DialogProperties (
284+ dismissOnBackPress = false ,
285+ dismissOnClickOutside = false ,
286+ usePlatformDefaultWidth = false
287+ )
288+ ) {
289+ Box (
290+ modifier = Modifier
291+ .fillMaxSize()
292+ .background(Color .Black .copy(alpha = scrimAlpha))
293+ .clickable(
294+ interactionSource = remember { MutableInteractionSource () },
295+ indication = null
296+ ) {}
297+ ) {
298+ Surface (
299+ shape = RoundedCornerShape (16 .dp),
300+ tonalElevation = 8 .dp,
301+ shadowElevation = 8 .dp,
302+ color = MaterialTheme .colorScheme.surface,
303+ modifier = Modifier
304+ .align(Alignment .Center )
305+ .padding(horizontal = 32 .dp)
306+ ) {
307+ Column (
308+ modifier = Modifier .padding(horizontal = 24 .dp, vertical = 20 .dp),
309+ horizontalAlignment = Alignment .CenterHorizontally ,
310+ verticalArrangement = Arrangement .spacedBy(12 .dp)
311+ ) {
312+ when (kind) {
313+ BlockingTaskHudKind .Loading -> CircularProgressIndicator (modifier = Modifier .size(32 .dp))
314+ BlockingTaskHudKind .Success -> Icon (
315+ imageVector = Icons .Filled .Check ,
316+ contentDescription = null ,
317+ tint = MaterialTheme .colorScheme.primary,
318+ modifier = Modifier .size(32 .dp)
319+ )
320+
321+ BlockingTaskHudKind .Error -> Icon (
322+ imageVector = Icons .Filled .Close ,
323+ contentDescription = null ,
324+ tint = MaterialTheme .colorScheme.error,
325+ modifier = Modifier .size(32 .dp)
326+ )
327+ }
328+
329+ text?.takeIf { it.isNotBlank() }?.let {
330+ Text (
331+ text = it,
332+ textAlign = TextAlign .Center ,
333+ style = MaterialTheme .typography.bodyMedium.copy(
334+ color = MaterialTheme .colorScheme.onSurfaceVariant
335+ )
336+ )
337+ }
338+ }
339+ }
340+ }
341+ }
203342}
0 commit comments