Skip to content
Merged
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ internal class SendFlowViewModel @Inject constructor(
is Event.OnContactClicked -> { state -> state }
is Event.SendInvite -> { state -> state }
is Event.SendCashToContact -> { state -> state }
is Event.NavigateToAmountEntry -> { state -> state }
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
is Event.ResolveCompleted -> { state -> state }
is Event.ResolveFailed -> { state -> state }
is Event.OnSendRequested -> { state -> state }
Expand All @@ -379,9 +379,7 @@ internal class SendFlowViewModel @Inject constructor(
)
)
}
is Event.SendComplete -> { state ->
state.copy(sendProgress = LoadingSuccessState())
}
is Event.SendComplete -> { state -> state }
Event.ContactNotResolved -> { state -> state }
}
}
Expand Down
239 changes: 126 additions & 113 deletions ui/components/src/main/kotlin/com/getcode/ui/components/SlideToConfirm.kt
Original file line number Diff line number Diff line change
@@ -1,59 +1,46 @@
@file:OptIn(ExperimentalMaterialApi::class)

package com.getcode.ui.components

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.SwipeProgress
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.SwipeableState
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowForward
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.material.rememberSwipeableState
import androidx.compose.material.swipeable
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Shape
Expand All @@ -74,7 +61,6 @@ import com.getcode.theme.DesignSystem
import com.getcode.theme.White20
import com.getcode.theme.White50
import com.getcode.ui.theme.CodeCircularProgressIndicator
import com.getcode.ui.core.addIf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -116,7 +102,6 @@ private object Thumb {
}

private object Track {
val VelocityThreshold = SwipeableDefaults.VelocityThreshold * 10
val Shape: Shape
@Composable get() = CodeTheme.shapes.small

Expand Down Expand Up @@ -176,15 +161,33 @@ fun SlideToConfirm(
) {
val currentIsLoading by rememberUpdatedState(isLoading)
val hapticFeedback = LocalHapticFeedback.current
val swipeState = rememberSwipeableState(
initialValue = Anchor.Start,
)
val density = LocalDensity.current
val thumbSize = Thumb.Size
val horizontalPadding = CodeTheme.dimens.grid.x1
val overhead = with(density) { (2 * horizontalPadding + thumbSize).toPx() }

val swipeState = remember {
AnchoredDraggableState(
initialValue = Anchor.Start,
positionalThreshold = { totalDistance ->
SlideToConfirmDefaults.SnapThreshold * (totalDistance - overhead).coerceAtLeast(0f)
},
velocityThreshold = { with(density) { 1250.dp.toPx() } },
snapAnimationSpec = spring(),
decayAnimationSpec = splineBasedDecay(density),
)
}

val composeScope = rememberCoroutineScope()
val hintState = remember { SlideHintState(composeScope) }

val swipeFraction by remember {
derivedStateOf { calculateSwipeFraction(swipeState.progress) }
derivedStateOf {
val offset = swipeState.offset
val end = swipeState.anchors.positionOf(Anchor.End)
if (offset.isNaN() || end.isNaN() || end <= 0f) 0f
else (offset / end).coerceIn(0f, 1f)
}
}

LaunchedEffect(swipeFraction, enabled) {
Expand Down Expand Up @@ -228,7 +231,7 @@ fun SlideToConfirm(
shape = trackShape,
color = trackColor,
) {
if (!isSuccess) {
if (!isSuccess && !isLoading) {
hint(swipeFraction, PaddingValues(horizontal = Thumb.Size + CodeTheme.dimens.grid.x2), label)
}

Expand Down Expand Up @@ -274,53 +277,39 @@ fun SlideToConfirm(
modifier = Modifier
.alpha(thumbAlpha)
.offset {
IntOffset(
x = swipeState.offset.value.roundToInt() + bumpFactor.roundToPx(),
y = 0
)
val rawOffset = swipeState.offset
val x = if (rawOffset.isNaN()) 0
else rawOffset.roundToInt() + bumpFactor.roundToPx()
IntOffset(x = x, y = 0)
},
)
}
}

private fun calculateSwipeFraction(progress: SwipeProgress<Anchor>): Float {
val atAnchor = progress.from == progress.to
val fromStart = progress.from == Anchor.Start
return if (atAnchor) {
if (fromStart) 0f else 1f
} else {
if (fromStart) progress.fraction else 1f - progress.fraction
}
}

enum class Anchor { Start, End }

@Composable
private fun Track(
swipeState: SwipeableState<Anchor>,
swipeState: AnchoredDraggableState<Anchor>,
enabled: Boolean,
modifier: Modifier = Modifier,
shape: Shape = Track.Shape,
color: Color = Track.Color,
content: @Composable (BoxScope.() -> Unit),
) {
val density = LocalDensity.current
var fullWidth by remember { mutableIntStateOf(0) }

val horizontalPadding = CodeTheme.dimens.grid.x1

val thumbSize = Thumb.Size
val startOfTrackPx = 0f
val endOfTrackPx = remember(fullWidth) {
with(density) { fullWidth - (2 * horizontalPadding + thumbSize).toPx() + thumbSize.value }
}

val snapThreshold = SlideToConfirmDefaults.SnapThreshold
val thresholds = { from: Anchor, _: Anchor ->
if (from == Anchor.Start) {
FractionalThreshold(snapThreshold)
} else {
FractionalThreshold(1f - snapThreshold)
LaunchedEffect(fullWidth) {
if (fullWidth > 0) {
swipeState.updateAnchors(
DraggableAnchors {
Anchor.Start at 0f
Anchor.End at fullWidth.toFloat()
}
)
}
}

Expand All @@ -329,21 +318,16 @@ private fun Track(
.onSizeChanged { fullWidth = it.width }
.height(thumbSize + CodeTheme.dimens.grid.x1)
.fillMaxWidth()
.swipeable(
enabled = enabled,
.anchoredDraggable(
state = swipeState,
enabled = enabled,
orientation = Orientation.Horizontal,
anchors = mapOf(
startOfTrackPx to Anchor.Start,
endOfTrackPx to Anchor.End,
),
thresholds = thresholds,
velocityThreshold = Track.VelocityThreshold,
)
.background(
color = color,
shape = shape,
)
.clip(shape)
.padding(
PaddingValues(
horizontal = horizontalPadding,
Expand All @@ -369,7 +353,7 @@ private fun Thumb(
contentAlignment = Alignment.Center
) {
Image(
Icons.Rounded.ArrowForward,
Icons.AutoMirrored.Rounded.ArrowForward,
contentDescription = null,
)
}
Expand All @@ -384,63 +368,92 @@ private fun calculateHintTextColor(swipeFraction: Float): Color {

@Preview
@Composable
private fun Preview() {
private fun InteractivePreview() {
var isLoading by remember { mutableStateOf(false) }
var isSuccess by remember { mutableStateOf(false) }
DesignSystem {
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.background(Color.White)
.fillMaxSize()
.background(Color.Black)
.padding(
horizontal = CodeTheme.dimens.inset,
vertical = CodeTheme.dimens.grid.x6
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3)
) {
Column(
modifier = Modifier
.background(Color.Black)
.padding(
horizontal = CodeTheme.dimens.inset,
vertical = CodeTheme.dimens.grid.x6
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3)
) {
SlideToConfirm(
modifier = Modifier.addIf(isSuccess) {
Modifier.clickable {
isLoading = false
isSuccess = false
}
},
isLoading = isLoading,
isSuccess = isSuccess,
onConfirm = { isLoading = true },
)
AnimatedContent(
targetState = !isLoading,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { show ->
if (show) {
TextButton(
shape = CircleShape,
onClick = { isLoading = false }) {
Text(
text = "Cancel",
style = CodeTheme.typography.caption,
color = White50
)
}
} else {
Spacer(modifier = Modifier.minimumInteractiveComponentSize())
}
SlideToConfirm(
isLoading = isLoading,
isSuccess = isSuccess,
onConfirm = { isLoading = true },
)

LaunchedEffect(isLoading) {
if (isLoading) {
delay(1500)
isSuccess = true
}
}

LaunchedEffect(isLoading) {
if (isLoading) {
delay(1500)
isSuccess = true
}
LaunchedEffect(isSuccess) {
if (isSuccess) {
delay(1500)
isLoading = false
isSuccess = false
}
}
}
}
}
}

@Preview
@Composable
private fun StatesPreview() {
DesignSystem {
Column(
modifier = Modifier
.background(Color.Black)
.padding(
horizontal = CodeTheme.dimens.inset,
vertical = CodeTheme.dimens.grid.x6
),
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3)
) {
Text(
text = "Enabled",
color = White50,
style = CodeTheme.typography.caption,
)
SlideToConfirm(
onConfirm = {},
)
Text(
text = "Disabled",
color = White50,
style = CodeTheme.typography.caption,
)
SlideToConfirm(
onConfirm = {},
enabled = false,
)
Text(
text = "Loading",
color = White50,
style = CodeTheme.typography.caption,
)
SlideToConfirm(
onConfirm = {},
isLoading = true,
)
Text(
text = "Success",
color = White50,
style = CodeTheme.typography.caption,
)
SlideToConfirm(
onConfirm = {},
isSuccess = true,
)
}
}
}
Loading