Skip to content

Commit fda941c

Browse files
committed
feat(tokens/tx/processing): add stateful loading indicator
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent f1e6db1 commit fda941c

6 files changed

Lines changed: 200 additions & 37 deletions

File tree

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@
408408
<string name="label_fromCurrencyDepreciation">from currency depreciation</string>
409409

410410
<string name="title_processingYourTransaction">Processing Your Transaction</string>
411-
<string name="subtitle_processingYourTransaction">This usually takes just under a minute</string>
411+
<string name="subtitle_processingYourTransaction">This usually takes about a minute</string>
412412
<string name="subtitle_wasAddedToYourWallet">was added to your Flipcash wallet</string>
413413
<string name="label_ofToken">of %1s</string>
414414

apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
2929
import com.flipcash.app.core.tokens.TokenSwapPurpose
3030
import com.flipcash.app.core.ui.buildNotifyButtonLabel
3131
import com.flipcash.app.theme.FlipcashPreview
32+
import com.flipcash.app.tokens.internal.components.processing.ProcessingLoadingIndicator
3233
import com.flipcash.app.tokens.ui.BuySellSwapTokenViewModel
3334
import com.flipcash.features.tokens.R
3435
import com.getcode.theme.CodeTheme
@@ -139,38 +140,15 @@ private fun TokenTxProcessingScreen(
139140
horizontalAlignment = Alignment.CenterHorizontally,
140141
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6),
141142
) {
142-
Crossfade(state.processingProgress) {
143-
Box(
144-
modifier = Modifier
145-
.fillMaxWidth(0.24f)
146-
.aspectRatio(1f),
147-
) {
148-
when (it.state) {
149-
LoadingSuccessState.State.Error -> Image(
150-
modifier = Modifier
151-
.matchParentSize(),
152-
painter = painterResource(R.drawable.ic_circle_exclamation_large),
153-
contentDescription = null,
154-
)
155-
156-
LoadingSuccessState.State.Idle -> Unit
157-
LoadingSuccessState.State.Loading -> CodeCircularProgressIndicator(
158-
modifier = Modifier
159-
.matchParentSize(),
160-
strokeWidth = CodeTheme.dimens.grid.x1,
161-
color = Color.White,
162-
backgroundColor = Color.White.copy(0.30f),
163-
strokeCap = StrokeCap.Butt,
164-
)
165-
166-
LoadingSuccessState.State.Success -> Image(
167-
modifier = Modifier
168-
.matchParentSize(),
169-
painter = painterResource(R.drawable.ic_circle_check_large),
170-
contentDescription = null,
171-
)
172-
}
173-
}
143+
Box(
144+
modifier = Modifier
145+
.fillMaxWidth(0.24f)
146+
.aspectRatio(1f),
147+
) {
148+
ProcessingLoadingIndicator(
149+
processingState = state.processingProgress,
150+
modifier = Modifier.matchParentSize(),
151+
)
174152
}
175153

176154
Column(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package com.flipcash.app.tokens.internal.components.processing
2+
3+
import androidx.compose.animation.AnimatedContent
4+
import androidx.compose.animation.AnimatedVisibility
5+
import androidx.compose.animation.core.FastOutSlowInEasing
6+
import androidx.compose.animation.core.animateFloatAsState
7+
import androidx.compose.animation.core.tween
8+
import androidx.compose.animation.fadeIn
9+
import androidx.compose.animation.fadeOut
10+
import androidx.compose.animation.togetherWith
11+
import androidx.compose.foundation.Image
12+
import androidx.compose.foundation.layout.Arrangement
13+
import androidx.compose.foundation.layout.Column
14+
import androidx.compose.foundation.layout.Row
15+
import androidx.compose.foundation.layout.Spacer
16+
import androidx.compose.foundation.layout.fillMaxSize
17+
import androidx.compose.foundation.layout.height
18+
import androidx.compose.foundation.layout.padding
19+
import androidx.compose.foundation.layout.size
20+
import androidx.compose.material.Button
21+
import androidx.compose.material.Text
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.LaunchedEffect
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableLongStateOf
26+
import androidx.compose.runtime.mutableStateOf
27+
import androidx.compose.runtime.remember
28+
import androidx.compose.runtime.setValue
29+
import androidx.compose.runtime.withFrameMillis
30+
import androidx.compose.ui.Alignment
31+
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.graphics.StrokeCap
34+
import androidx.compose.ui.res.painterResource
35+
import androidx.compose.ui.tooling.preview.Preview
36+
import androidx.compose.ui.unit.dp
37+
import com.flipcash.app.theme.FlipcashPreview
38+
import com.flipcash.features.tokens.R
39+
import com.getcode.theme.CodeTheme
40+
import com.getcode.ui.theme.CodeButton
41+
import com.getcode.ui.theme.CodeCircularProgressIndicator
42+
import com.getcode.view.LoadingSuccessState
43+
import kotlinx.coroutines.isActive
44+
import kotlin.math.pow
45+
import kotlin.time.Duration
46+
import kotlin.time.Duration.Companion.seconds
47+
48+
@Composable
49+
internal fun ProcessingLoadingIndicator(
50+
processingState: LoadingSuccessState,
51+
modifier: Modifier = Modifier,
52+
duration: Duration = 60.seconds,
53+
) {
54+
var elapsedMs by remember(processingState) { mutableLongStateOf(0L) }
55+
val isLoading = processingState.state is LoadingSuccessState.State.Loading
56+
57+
LaunchedEffect(isLoading) {
58+
if (isLoading) {
59+
val startTime = withFrameMillis { it } - elapsedMs
60+
while (isActive) {
61+
withFrameMillis { frameTime ->
62+
elapsedMs = (frameTime - startTime).coerceAtLeast(0)
63+
}
64+
}
65+
}
66+
}
67+
68+
val timedProgress = remember(elapsedMs) {
69+
val fraction = (elapsedMs / duration.inWholeMilliseconds.toFloat()).coerceIn(0f, 1f)
70+
val eased = 1f - (1f - fraction).pow(2)
71+
0.05f + eased * 0.85f
72+
}
73+
74+
// Tracks whether the fill-to-100% animation has finished.
75+
var fillComplete by remember { mutableStateOf(false) }
76+
77+
val animatedProgress by animateFloatAsState(
78+
targetValue = when (processingState.state) {
79+
LoadingSuccessState.State.Loading -> timedProgress
80+
LoadingSuccessState.State.Success -> 1f
81+
LoadingSuccessState.State.Error -> timedProgress
82+
else -> 0.05f
83+
},
84+
animationSpec = when (processingState.state) {
85+
LoadingSuccessState.State.Success -> tween(durationMillis = 400, easing = FastOutSlowInEasing)
86+
else -> tween(durationMillis = 100)
87+
},
88+
finishedListener = { value ->
89+
if (processingState.state is LoadingSuccessState.State.Success && value == 1f) {
90+
fillComplete = true
91+
}
92+
},
93+
label = "progress",
94+
)
95+
96+
// Show the ring while loading or filling to 100%.
97+
// Once filled, show the result icon via Crossfade.
98+
val displayedState = remember(processingState.state, fillComplete) {
99+
when {
100+
processingState.state is LoadingSuccessState.State.Success && !fillComplete -> LoadingSuccessState.State.Loading
101+
else -> processingState.state
102+
}
103+
}
104+
105+
// Reset fillComplete when a new loading cycle starts.
106+
LaunchedEffect(isLoading) {
107+
if (isLoading) fillComplete = false
108+
}
109+
AnimatedContent(
110+
targetState = displayedState,
111+
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
112+
modifier = modifier,
113+
) { targetState ->
114+
when (targetState) {
115+
LoadingSuccessState.State.Error -> Image(
116+
modifier = modifier,
117+
painter = painterResource(R.drawable.ic_circle_exclamation_large),
118+
contentDescription = null,
119+
)
120+
LoadingSuccessState.State.Idle -> Spacer(modifier)
121+
LoadingSuccessState.State.Loading -> CodeCircularProgressIndicator(
122+
modifier = modifier,
123+
progress = animatedProgress,
124+
strokeWidth = CodeTheme.dimens.grid.x1,
125+
color = Color.White,
126+
backgroundColor = Color.White.copy(0.30f),
127+
strokeCap = StrokeCap.Butt,
128+
)
129+
LoadingSuccessState.State.Success -> Image(
130+
modifier = modifier,
131+
painter = painterResource(R.drawable.ic_circle_check_large),
132+
contentDescription = null,
133+
)
134+
}
135+
}
136+
}
137+
138+
@Preview
139+
@Composable
140+
private fun ProcessingLoadingIndicatorPreview() {
141+
FlipcashPreview(showBackground = true) {
142+
var state by remember { mutableStateOf(LoadingSuccessState()) }
143+
144+
Column(
145+
modifier = Modifier
146+
.fillMaxSize()
147+
.padding(24.dp),
148+
horizontalAlignment = Alignment.CenterHorizontally,
149+
verticalArrangement = Arrangement.Center,
150+
) {
151+
ProcessingLoadingIndicator(
152+
processingState = state,
153+
modifier = Modifier.size(48.dp),
154+
duration = 20.seconds,
155+
)
156+
157+
Spacer(modifier = Modifier.height(32.dp))
158+
159+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
160+
CodeButton(onClick = { state = LoadingSuccessState(loading = true) }) {
161+
Text("Start")
162+
}
163+
CodeButton(onClick = { state = LoadingSuccessState(success = true) }) {
164+
Text("Success")
165+
}
166+
CodeButton(onClick = { state = LoadingSuccessState(error = true) }) {
167+
Text("Error")
168+
}
169+
CodeButton(onClick = { state = LoadingSuccessState() }) {
170+
Text("Reset")
171+
}
172+
}
173+
}
174+
}
175+
}

services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/OpenCodeExchange.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
66
import com.getcode.opencode.controllers.CurrencyController
77
import com.getcode.opencode.exchange.Exchange
88
import com.getcode.opencode.internal.extensions.fromCode
9-
import com.getcode.opencode.internal.manager.VerifiedProtoManager
109
import com.getcode.opencode.internal.model.LiveMintDataResponse
1110
import com.getcode.opencode.model.financial.Currency
1211
import com.getcode.opencode.model.financial.CurrencyCode

ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ fun CodeButton(
247247
bottom = CodeTheme.dimens.grid.x2,
248248
),
249249
overrideContentPadding: Boolean = false,
250-
buttonState: ButtonState = ButtonState.Bordered,
250+
buttonState: ButtonState = ButtonState.Filled,
251251
textColor: Color = Color.Unspecified,
252252
shape: Shape = CodeTheme.shapes.small,
253253
style: TextStyle = CodeTheme.typography.textMedium,
@@ -280,7 +280,7 @@ fun CodeButton(
280280
isLoading: Boolean = false,
281281
isSuccess: Boolean = false,
282282
enabled: Boolean = true,
283-
buttonState: ButtonState = ButtonState.Bordered,
283+
buttonState: ButtonState = ButtonState.Filled,
284284
shape: Shape = CodeTheme.shapes.small,
285285
contentPadding: PaddingValues = PaddingValues(
286286
top = CodeTheme.dimens.grid.x2,

ui/components/src/main/kotlin/com/getcode/ui/theme/CodeCircularProgressIndicator.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,15 @@ fun CodeCircularProgressIndicator(
1616
strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth,
1717
backgroundColor: Color = Color.Transparent,
1818
strokeCap: StrokeCap = StrokeCap.Round,
19-
) = CircularProgressIndicator(modifier, color, strokeWidth, backgroundColor, strokeCap)
19+
) = CircularProgressIndicator(modifier, color, strokeWidth, backgroundColor, strokeCap)
20+
21+
22+
@Composable
23+
fun CodeCircularProgressIndicator(
24+
progress: Float,
25+
modifier: Modifier = Modifier,
26+
color: Color = White,
27+
strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth,
28+
backgroundColor: Color = Color.Transparent,
29+
strokeCap: StrokeCap = StrokeCap.Round,
30+
) = CircularProgressIndicator(progress, modifier, color, strokeWidth, backgroundColor, strokeCap)

0 commit comments

Comments
 (0)