From 038fd8d5431a9fa4333772d46b700929771e3a0c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:47:05 +0200 Subject: [PATCH] receive --- .../android/wallet/ui/SuccessAnimation.kt | 329 +++++++++++++----- .../bitchat/android/wallet/ui/WalletScreen.kt | 14 + .../wallet/viewmodel/WalletViewModel.kt | 73 +++- 3 files changed, 330 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/wallet/ui/SuccessAnimation.kt b/app/src/main/java/com/bitchat/android/wallet/ui/SuccessAnimation.kt index 713e0b5d..10f70368 100644 --- a/app/src/main/java/com/bitchat/android/wallet/ui/SuccessAnimation.kt +++ b/app/src/main/java/com/bitchat/android/wallet/ui/SuccessAnimation.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -31,70 +32,87 @@ fun SuccessAnimation( onAnimationComplete: () -> Unit, 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) { - delay(2500) // Show for 2.5 seconds - isVisible = false - delay(300) // Allow fade out animation to complete + // Start with fade in + isVisible = true + delay(2000) // Show for 2 seconds + // Start exit animation + startExit = true + delay(500) // Allow fade out animation to complete onAnimationComplete() } - // Scale animation for the icon + // Wobble/tada animation for the icon 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( initialValue = 1f, - targetValue = 1.1f, + targetValue = if (isVisible && !startExit) 1.05f else 1f, animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing), + animation = tween(durationMillis = 300, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse ), label = "icon_scale" ) - // Pulsing background effect - val backgroundAlpha by infiniteTransition.animateFloat( - initialValue = 0.9f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1500, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse + // 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 = "background_alpha" - ) - - // Entry animation - val scale by animateFloatAsState( - targetValue = if (isVisible) 1f else 0.8f, - animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow), - label = "entry_scale" + label = "content_scale" ) val alpha by animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 300), - label = "entry_alpha" + 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 * alpha)), + .background(Color.Black.copy(alpha = backgroundAlpha)), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, 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( contentAlignment = Alignment.Center, modifier = Modifier.size(120.dp) ) { - // Pulsing background circle + // Outer glow circle Box( modifier = Modifier .size(100.dp) @@ -104,7 +122,7 @@ fun SuccessAnimation( ) ) - // Inner circle + // Main success circle with wobble Box( modifier = Modifier .size(80.dp) @@ -112,7 +130,10 @@ fun SuccessAnimation( Color(0xFF00C851), CircleShape ) - .scale(iconScale), + .scale(iconScale) + .graphicsLayer { + rotationZ = iconRotation + }, contentAlignment = Alignment.Center ) { Icon( @@ -124,62 +145,202 @@ fun SuccessAnimation( } } - // Success message - Text( - text = "SUCCESS!", - color = Color(0xFF00C851), - fontSize = 28.sp, - 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) + // Success message with smooth fade + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.graphicsLayer { this.alpha = alpha } ) { - for (i in 0..2) { - val dotAlpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 600), - repeatMode = RepeatMode.Reverse, - initialStartOffset = StartOffset(i * 200) - ), - label = "dot_$i" - ) - - Box( - modifier = Modifier - .size(8.dp) - .background( - Color(0xFF00C851).copy(alpha = dotAlpha), - CircleShape - ) + Text( + text = "SUCCESS!", + color = Color(0xFF00C851), + fontSize = 28.sp, + 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) + ) + } + } + } + } +} + +/** + * 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) + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/wallet/ui/WalletScreen.kt b/app/src/main/java/com/bitchat/android/wallet/ui/WalletScreen.kt index 48c90262..0913b3d1 100644 --- a/app/src/main/java/com/bitchat/android/wallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitchat/android/wallet/ui/WalletScreen.kt @@ -35,6 +35,8 @@ fun WalletScreen( val showReceiveDialog by walletViewModel.showReceiveDialog.observeAsState(false) val showSuccessAnimation by walletViewModel.showSuccessAnimation.observeAsState(false) val successAnimationData by walletViewModel.successAnimationData.observeAsState() + val showFailureAnimation by walletViewModel.showFailureAnimation.observeAsState(false) + val failureAnimationData by walletViewModel.failureAnimationData.observeAsState() // Back handler for the wallet fun handleBackPress(): Boolean { @@ -135,6 +137,18 @@ fun WalletScreen( 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 diff --git a/app/src/main/java/com/bitchat/android/wallet/viewmodel/WalletViewModel.kt b/app/src/main/java/com/bitchat/android/wallet/viewmodel/WalletViewModel.kt index ef65dc71..fbb69cae 100644 --- a/app/src/main/java/com/bitchat/android/wallet/viewmodel/WalletViewModel.kt +++ b/app/src/main/java/com/bitchat/android/wallet/viewmodel/WalletViewModel.kt @@ -93,6 +93,13 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) private val _successAnimationData = MutableLiveData(null) val successAnimationData: LiveData = _successAnimationData + // Failure animation state + private val _showFailureAnimation = MutableLiveData(false) + val showFailureAnimation: LiveData = _showFailureAnimation + + private val _failureAnimationData = MutableLiveData(null) + val failureAnimationData: LiveData = _failureAnimationData + // State management private var pollingJob: kotlinx.coroutines.Job? = null @@ -109,6 +116,14 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) val description: String ) + /** + * Data for failure animation display + */ + data class FailureAnimationData( + val errorMessage: String, + val operationType: String = "Token Receive" + ) + enum class SuccessAnimationType { CASHU_RECEIVED, CASHU_SENT, @@ -434,6 +449,17 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) _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 private var backHandler: (() -> Boolean)? = null @@ -560,11 +586,54 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) }.onFailure { 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 -> - _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 { _isLoading.value = false }