This commit is contained in:
callebtc
2025-07-15 15:47:05 +02:00
parent 51736bb47b
commit 038fd8d543
3 changed files with 330 additions and 86 deletions
@@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -31,70 +32,87 @@ fun SuccessAnimation(
onAnimationComplete: () -> Unit, onAnimationComplete: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isVisible by remember { mutableStateOf(true) } var isVisible by remember { mutableStateOf(false) }
var startExit by remember { mutableStateOf(false) }
// Auto-hide after animation duration // Control animation timing
LaunchedEffect(animationData) { LaunchedEffect(animationData) {
delay(2500) // Show for 2.5 seconds // Start with fade in
isVisible = false isVisible = true
delay(300) // Allow fade out animation to complete delay(2000) // Show for 2 seconds
// Start exit animation
startExit = true
delay(500) // Allow fade out animation to complete
onAnimationComplete() onAnimationComplete()
} }
// Scale animation for the icon // Wobble/tada animation for the icon
val infiniteTransition = rememberInfiniteTransition(label = "success_animation") val infiniteTransition = rememberInfiniteTransition(label = "success_animation")
val iconRotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = if (isVisible && !startExit) 6f else 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 150, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "icon_wobble"
)
val iconScale by infiniteTransition.animateFloat( val iconScale by infiniteTransition.animateFloat(
initialValue = 1f, initialValue = 1f,
targetValue = 1.1f, targetValue = if (isVisible && !startExit) 1.05f else 1f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing), animation = tween(durationMillis = 300, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse repeatMode = RepeatMode.Reverse
), ),
label = "icon_scale" label = "icon_scale"
) )
// Pulsing background effect // Smooth fade in/out animations
val backgroundAlpha by infiniteTransition.animateFloat( val contentScale by animateFloatAsState(
initialValue = 0.9f, targetValue = if (isVisible && !startExit) 1f else 0.9f,
targetValue = 1f, animationSpec = spring(
animationSpec = infiniteRepeatable( dampingRatio = 0.7f,
animation = tween(durationMillis = 1500, easing = FastOutSlowInEasing), stiffness = Spring.StiffnessMedium
repeatMode = RepeatMode.Reverse
), ),
label = "background_alpha" label = "content_scale"
)
// Entry animation
val scale by animateFloatAsState(
targetValue = if (isVisible) 1f else 0.8f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow),
label = "entry_scale"
) )
val alpha by animateFloatAsState( val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f, targetValue = if (isVisible && !startExit) 1f else 0f,
animationSpec = tween(durationMillis = 300), animationSpec = tween(
label = "entry_alpha" durationMillis = if (startExit) 400 else 600,
easing = if (startExit) FastOutLinearInEasing else LinearOutSlowInEasing
),
label = "content_alpha"
)
val backgroundAlpha by animateFloatAsState(
targetValue = if (isVisible && !startExit) 0.95f else 0f,
animationSpec = tween(
durationMillis = if (startExit) 400 else 600
),
label = "background_alpha"
) )
if (alpha > 0f) { if (alpha > 0f) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = backgroundAlpha * alpha)), .background(Color.Black.copy(alpha = backgroundAlpha)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.scale(scale) modifier = Modifier.scale(contentScale)
) { ) {
// Success icon with pulsing circle background // Success icon with wobble/tada effect
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.size(120.dp) modifier = Modifier.size(120.dp)
) { ) {
// Pulsing background circle // Outer glow circle
Box( Box(
modifier = Modifier modifier = Modifier
.size(100.dp) .size(100.dp)
@@ -104,7 +122,7 @@ fun SuccessAnimation(
) )
) )
// Inner circle // Main success circle with wobble
Box( Box(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
@@ -112,7 +130,10 @@ fun SuccessAnimation(
Color(0xFF00C851), Color(0xFF00C851),
CircleShape CircleShape
) )
.scale(iconScale), .scale(iconScale)
.graphicsLayer {
rotationZ = iconRotation
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -124,62 +145,202 @@ fun SuccessAnimation(
} }
} }
// Success message // Success message with smooth fade
Text( Column(
text = "SUCCESS!", horizontalAlignment = Alignment.CenterHorizontally,
color = Color(0xFF00C851), verticalArrangement = Arrangement.spacedBy(8.dp),
fontSize = 28.sp, modifier = Modifier.graphicsLayer { this.alpha = alpha }
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
letterSpacing = 2.sp
)
// Amount
Text(
text = formatAmount(animationData.amount, animationData.unit),
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
// Description
Text(
text = animationData.description,
color = Color.Gray,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
// Animated checkmark or icon indicator
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
for (i in 0..2) { Text(
val dotAlpha by infiniteTransition.animateFloat( text = "SUCCESS!",
initialValue = 0.3f, color = Color(0xFF00C851),
targetValue = 1f, fontSize = 28.sp,
animationSpec = infiniteRepeatable( fontWeight = FontWeight.Bold,
animation = tween(durationMillis = 600), fontFamily = FontFamily.Monospace,
repeatMode = RepeatMode.Reverse, letterSpacing = 2.sp
initialStartOffset = StartOffset(i * 200) )
),
label = "dot_$i" // Amount
) Text(
text = formatAmount(animationData.amount, animationData.unit),
Box( color = Color.White,
modifier = Modifier fontSize = 24.sp,
.size(8.dp) fontWeight = FontWeight.Bold,
.background( fontFamily = FontFamily.Monospace
Color(0xFF00C851).copy(alpha = dotAlpha), )
CircleShape
) // Description
Text(
text = animationData.description,
color = Color.Gray,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
}
}
}
}
/**
* Fullscreen failure animation component for wallet operations
*/
@Composable
fun FailureAnimation(
errorMessage: String,
operationType: String = "Operation",
onAnimationComplete: () -> Unit,
modifier: Modifier = Modifier
) {
var isVisible by remember { mutableStateOf(false) }
var startExit by remember { mutableStateOf(false) }
// Control animation timing
LaunchedEffect(errorMessage) {
// Start with fade in
isVisible = true
delay(3000) // Show for 3 seconds (longer for error message)
// Start exit animation
startExit = true
delay(500) // Allow fade out animation to complete
onAnimationComplete()
}
// Shake animation for the error icon
val infiniteTransition = rememberInfiniteTransition(label = "failure_animation")
val iconShake by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = if (isVisible && !startExit) 3f else 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 100, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "icon_shake"
)
val iconScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = if (isVisible && !startExit) 1.03f else 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "icon_scale"
)
// Smooth fade in/out animations
val contentScale by animateFloatAsState(
targetValue = if (isVisible && !startExit) 1f else 0.9f,
animationSpec = spring(
dampingRatio = 0.7f,
stiffness = Spring.StiffnessMedium
),
label = "content_scale"
)
val alpha by animateFloatAsState(
targetValue = if (isVisible && !startExit) 1f else 0f,
animationSpec = tween(
durationMillis = if (startExit) 400 else 600,
easing = if (startExit) FastOutLinearInEasing else LinearOutSlowInEasing
),
label = "content_alpha"
)
val backgroundAlpha by animateFloatAsState(
targetValue = if (isVisible && !startExit) 0.95f else 0f,
animationSpec = tween(
durationMillis = if (startExit) 400 else 600
),
label = "background_alpha"
)
if (alpha > 0f) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = backgroundAlpha)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.scale(contentScale)
) {
// Error icon with shake effect
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(120.dp)
) {
// Outer glow circle (red)
Box(
modifier = Modifier
.size(100.dp)
.background(
Color(0xFFFF4444).copy(alpha = 0.2f),
CircleShape
)
)
// Main error circle with shake
Box(
modifier = Modifier
.size(80.dp)
.background(
Color(0xFFFF4444),
CircleShape
)
.scale(iconScale)
.graphicsLayer {
translationX = iconShake
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Error",
tint = Color.White,
modifier = Modifier.size(40.dp)
) )
} }
} }
// Error message with smooth fade
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.graphicsLayer { this.alpha = alpha }
) {
Text(
text = "FAILED",
color = Color(0xFFFF4444),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
letterSpacing = 2.sp
)
// Operation type
Text(
text = "$operationType Failed",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
// Error message
Text(
text = errorMessage,
color = Color.Gray,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
} }
} }
} }
@@ -35,6 +35,8 @@ fun WalletScreen(
val showReceiveDialog by walletViewModel.showReceiveDialog.observeAsState(false) val showReceiveDialog by walletViewModel.showReceiveDialog.observeAsState(false)
val showSuccessAnimation by walletViewModel.showSuccessAnimation.observeAsState(false) val showSuccessAnimation by walletViewModel.showSuccessAnimation.observeAsState(false)
val successAnimationData by walletViewModel.successAnimationData.observeAsState() val successAnimationData by walletViewModel.successAnimationData.observeAsState()
val showFailureAnimation by walletViewModel.showFailureAnimation.observeAsState(false)
val failureAnimationData by walletViewModel.failureAnimationData.observeAsState()
// Back handler for the wallet // Back handler for the wallet
fun handleBackPress(): Boolean { fun handleBackPress(): Boolean {
@@ -135,6 +137,18 @@ fun WalletScreen(
modifier = Modifier.zIndex(10f) modifier = Modifier.zIndex(10f)
) )
} }
// Failure animation overlay
if (showFailureAnimation && failureAnimationData != null) {
FailureAnimation(
errorMessage = failureAnimationData!!.errorMessage,
operationType = failureAnimationData!!.operationType,
onAnimationComplete = {
walletViewModel.hideFailureAnimation()
},
modifier = Modifier.zIndex(10f)
)
}
} }
// Handle dialog states - sync local view state with ViewModel dialog state // Handle dialog states - sync local view state with ViewModel dialog state
@@ -93,6 +93,13 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
private val _successAnimationData = MutableLiveData<SuccessAnimationData?>(null) private val _successAnimationData = MutableLiveData<SuccessAnimationData?>(null)
val successAnimationData: LiveData<SuccessAnimationData?> = _successAnimationData val successAnimationData: LiveData<SuccessAnimationData?> = _successAnimationData
// Failure animation state
private val _showFailureAnimation = MutableLiveData<Boolean>(false)
val showFailureAnimation: LiveData<Boolean> = _showFailureAnimation
private val _failureAnimationData = MutableLiveData<FailureAnimationData?>(null)
val failureAnimationData: LiveData<FailureAnimationData?> = _failureAnimationData
// State management // State management
private var pollingJob: kotlinx.coroutines.Job? = null private var pollingJob: kotlinx.coroutines.Job? = null
@@ -109,6 +116,14 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
val description: String val description: String
) )
/**
* Data for failure animation display
*/
data class FailureAnimationData(
val errorMessage: String,
val operationType: String = "Token Receive"
)
enum class SuccessAnimationType { enum class SuccessAnimationType {
CASHU_RECEIVED, CASHU_RECEIVED,
CASHU_SENT, CASHU_SENT,
@@ -434,6 +449,17 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
_successAnimationData.value = null _successAnimationData.value = null
} }
// Failure animation management
fun showFailureAnimation(animationData: FailureAnimationData) {
_failureAnimationData.value = animationData
_showFailureAnimation.value = true
}
fun hideFailureAnimation() {
_showFailureAnimation.value = false
_failureAnimationData.value = null
}
// Back navigation handler // Back navigation handler
private var backHandler: (() -> Boolean)? = null private var backHandler: (() -> Boolean)? = null
@@ -560,11 +586,54 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}.onFailure { error -> }.onFailure { error ->
Log.e(TAG, "Failed to save transaction", error) Log.e(TAG, "Failed to save transaction", error)
_errorMessage.value = "Failed to save transaction: ${error.message}"
// Clear token input and close dialog
clearTokenInput()
// Show failure animation for transaction save error
val failureData = FailureAnimationData(
errorMessage = "Failed to save transaction: ${error.message}",
operationType = "Token Receive"
)
showFailureAnimation(failureData)
// Close receive dialog after animation starts
delay(500)
hideReceiveDialog()
} }
}.onFailure { error -> }.onFailure { error ->
_errorMessage.value = "Failed to receive token: ${error.message}" Log.e(TAG, "Failed to receive token", error)
// Clear token input and close dialog
clearTokenInput()
// Show failure animation for token receive error
val failureData = FailureAnimationData(
errorMessage = error.message ?: "Unknown error occurred",
operationType = "Token Receive"
)
showFailureAnimation(failureData)
// Close receive dialog after animation starts
delay(500)
hideReceiveDialog()
} }
} catch (e: Exception) {
Log.e(TAG, "Exception in receiveCashuToken", e)
// Clear token input and close dialog
clearTokenInput()
// Show failure animation for unexpected error
val failureData = FailureAnimationData(
errorMessage = e.message ?: "Unexpected error occurred",
operationType = "Token Receive"
)
showFailureAnimation(failureData)
// Close receive dialog after animation starts
delay(500)
hideReceiveDialog()
} finally { } finally {
_isLoading.value = false _isLoading.value = false
} }