Skip to content

Commit 981e9d2

Browse files
authored
Merge pull request #466 from theleftbit/fix-blocking-task-hud
fix(android): blocking task HUD behavior
2 parents 2dbcbdf + d838c05 commit 981e9d2

1 file changed

Lines changed: 194 additions & 55 deletions

File tree

Lines changed: 194 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
package bswinterface.kit
22

3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.clickable
5+
import androidx.compose.foundation.interaction.MutableInteractionSource
36
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Box
48
import androidx.compose.foundation.layout.Column
9+
import androidx.compose.foundation.layout.fillMaxSize
510
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.size
612
import 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
716
import androidx.compose.material3.AlertDialog
817
import androidx.compose.material3.CircularProgressIndicator
18+
import androidx.compose.material3.Icon
919
import androidx.compose.material3.MaterialTheme
1020
import androidx.compose.material3.Surface
1121
import androidx.compose.material3.Text
1222
import androidx.compose.material3.TextButton
1323
import androidx.compose.runtime.Composable
24+
import androidx.compose.runtime.CompositionLocalProvider
1425
import androidx.compose.runtime.LaunchedEffect
1526
import androidx.compose.runtime.Stable
1627
import androidx.compose.runtime.getValue
@@ -19,13 +30,16 @@ import androidx.compose.runtime.mutableStateOf
1930
import androidx.compose.runtime.remember
2031
import androidx.compose.runtime.rememberCoroutineScope
2132
import androidx.compose.runtime.setValue
33+
import androidx.compose.runtime.staticCompositionLocalOf
2234
import androidx.compose.ui.Alignment
2335
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.graphics.Color
2437
import androidx.compose.ui.text.style.TextAlign
2538
import androidx.compose.ui.unit.dp
2639
import androidx.compose.ui.window.Dialog
2740
import androidx.compose.ui.window.DialogProperties
2841
import kotlinx.coroutines.CancellationException
42+
import kotlinx.coroutines.delay
2943
import kotlinx.coroutines.launch
3044

3145
sealed 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
6775
fun <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
7299
fun <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
91129
fun <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

Comments
 (0)