From 51736bb47b1c94393bbf27923bc83c51a5c5ecbc Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:39:06 +0200 Subject: [PATCH] nice --- .../java/com/bitchat/android/ui/ChatScreen.kt | 9 +- .../com/bitchat/android/ui/MainAppScreen.kt | 7 +- .../android/wallet/ui/ReceiveDialog.kt | 10 +- .../android/wallet/ui/SuccessAnimation.kt | 214 ++++++++++++++++++ .../bitchat/android/wallet/ui/WalletScreen.kt | 135 ++++++----- .../wallet/viewmodel/WalletViewModel.kt | 110 +++++++++ 6 files changed, 414 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/wallet/ui/SuccessAnimation.kt diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 15ea743b..5dd543b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -51,7 +51,8 @@ import java.util.* @Composable fun ChatScreen( viewModel: ChatViewModel, - onWalletClick: () -> Unit = {} + onWalletClick: () -> Unit = {}, + onWalletClickWithToken: ((String) -> Unit)? = null ) { val colorScheme = MaterialTheme.colorScheme val messages by viewModel.messages.observeAsState(emptyList()) @@ -109,11 +110,9 @@ fun ChatScreen( messages = displayMessages, currentUserNickname = nickname, meshService = viewModel.meshService, - onCashuPaymentClick = { token -> + onCashuPaymentClick = { parsedToken -> // Open wallet with the receive dialog pre-filled with this token - onWalletClick() - // TODO: We need to pass the token to the wallet somehow - // This could be done through a shared state, intent, or callback + onWalletClickWithToken?.invoke(parsedToken.originalString) ?: onWalletClick() }, modifier = Modifier.fillMaxSize() ) diff --git a/app/src/main/java/com/bitchat/android/ui/MainAppScreen.kt b/app/src/main/java/com/bitchat/android/ui/MainAppScreen.kt index 1971253d..51571334 100644 --- a/app/src/main/java/com/bitchat/android/ui/MainAppScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/MainAppScreen.kt @@ -54,7 +54,12 @@ fun MainAppScreen( when (selectedTab) { 0 -> ChatScreen( viewModel = chatViewModel, - onWalletClick = { selectedTab = 1 } // Switch to wallet tab when header button is clicked + onWalletClick = { selectedTab = 1 }, // Switch to wallet tab when header button is clicked + onWalletClickWithToken = { token -> + // Switch to wallet and open receive dialog with the token + selectedTab = 1 + walletViewModel.openReceiveDialogWithToken(token) + } ) 1 -> WalletScreen( walletViewModel = walletViewModel, diff --git a/app/src/main/java/com/bitchat/android/wallet/ui/ReceiveDialog.kt b/app/src/main/java/com/bitchat/android/wallet/ui/ReceiveDialog.kt index 031d45e6..abddbb36 100644 --- a/app/src/main/java/com/bitchat/android/wallet/ui/ReceiveDialog.kt +++ b/app/src/main/java/com/bitchat/android/wallet/ui/ReceiveDialog.kt @@ -174,7 +174,7 @@ private fun CashuReceiveContent( decodedToken: CashuToken?, isLoading: Boolean ) { - var token by remember { mutableStateOf("") } + val token by viewModel.tokenInput.observeAsState("") val clipboardManager = LocalClipboardManager.current if (decodedToken != null) { @@ -302,10 +302,7 @@ private fun CashuReceiveContent( OutlinedTextField( value = token, onValueChange = { - token = it - if (it.isNotEmpty() && it.startsWith("cashu")) { - viewModel.decodeCashuToken(it) - } + viewModel.setTokenInput(it) }, label = { Text( @@ -354,8 +351,7 @@ private fun CashuReceiveContent( onClick = { clipboardManager.getText()?.text?.let { clipText -> if (clipText.startsWith("cashu")) { - token = clipText - viewModel.decodeCashuToken(clipText) + viewModel.setTokenInput(clipText) } } }, 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 new file mode 100644 index 00000000..713e0b5d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wallet/ui/SuccessAnimation.kt @@ -0,0 +1,214 @@ +package com.bitchat.android.wallet.ui + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.wallet.viewmodel.WalletViewModel +import kotlinx.coroutines.delay + +/** + * Fullscreen success animation component for wallet operations + */ +@Composable +fun SuccessAnimation( + animationData: WalletViewModel.SuccessAnimationData, + onAnimationComplete: () -> Unit, + modifier: Modifier = Modifier +) { + var isVisible by remember { mutableStateOf(true) } + + // Auto-hide after animation duration + LaunchedEffect(animationData) { + delay(2500) // Show for 2.5 seconds + isVisible = false + delay(300) // Allow fade out animation to complete + onAnimationComplete() + } + + // Scale animation for the icon + val infiniteTransition = rememberInfiniteTransition(label = "success_animation") + val iconScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, 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 + ), + 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" + ) + + val alpha by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "entry_alpha" + ) + + if (alpha > 0f) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = backgroundAlpha * alpha)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.scale(scale) + ) { + // Success icon with pulsing circle background + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(120.dp) + ) { + // Pulsing background circle + Box( + modifier = Modifier + .size(100.dp) + .background( + Color(0xFF00C851).copy(alpha = 0.2f), + CircleShape + ) + ) + + // Inner circle + Box( + modifier = Modifier + .size(80.dp) + .background( + Color(0xFF00C851), + CircleShape + ) + .scale(iconScale), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = getIconForAnimationType(animationData.type), + contentDescription = "Success", + tint = Color.Black, + modifier = Modifier.size(40.dp) + ) + } + } + + // 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) + ) { + 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 + ) + ) + } + } + } + } + } +} + +/** + * Get appropriate icon for animation type + */ +private fun getIconForAnimationType(type: WalletViewModel.SuccessAnimationType): ImageVector { + return when (type) { + WalletViewModel.SuccessAnimationType.CASHU_RECEIVED -> Icons.Filled.Download + WalletViewModel.SuccessAnimationType.CASHU_SENT -> Icons.Filled.Upload + WalletViewModel.SuccessAnimationType.LIGHTNING_RECEIVED -> Icons.Filled.FlashOn + WalletViewModel.SuccessAnimationType.LIGHTNING_SENT -> Icons.AutoMirrored.Filled.Send + } +} + +/** + * Format amount for display + */ +private fun formatAmount(amount: Long, unit: String): String { + return when (unit.lowercase()) { + "sat", "sats" -> { + when { + amount >= 100_000_000 -> String.format("%.2f BTC", amount / 100_000_000.0) + amount >= 1000 -> String.format("%,d sats", amount) + else -> "$amount sats" + } + } + else -> "$amount $unit" + } +} \ No newline at end of file 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 2a5cd877..48c90262 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 @@ -13,9 +13,12 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.lifecycle.viewmodel.compose.viewModel import com.bitchat.android.wallet.viewmodel.WalletViewModel +// Import the SuccessAnimation component (same package, no need for full path) + /** * Main wallet screen with bottom navigation */ @@ -30,6 +33,8 @@ fun WalletScreen( var showSendView by remember { mutableStateOf(false) } val showSendDialog by walletViewModel.showSendDialog.observeAsState(false) val showReceiveDialog by walletViewModel.showReceiveDialog.observeAsState(false) + val showSuccessAnimation by walletViewModel.showSuccessAnimation.observeAsState(false) + val successAnimationData by walletViewModel.successAnimationData.observeAsState() // Back handler for the wallet fun handleBackPress(): Boolean { @@ -64,74 +69,88 @@ fun WalletScreen( } } - if (showReceiveView) { - // Full-screen ReceiveView - ReceiveView( - viewModel = walletViewModel, - onNavigateBack = { - showReceiveView = false - walletViewModel.hideReceiveDialog() - } - ) - } else if (showSendView) { - // Full-screen SendView - SendView( - viewModel = walletViewModel, - onNavigateBack = { - showSendView = false - walletViewModel.hideSendDialog() - } - ) - } else { - Column(modifier = modifier.fillMaxSize()) { - // Content - when (selectedTab) { - 0 -> WalletOverview( - viewModel = walletViewModel, - onBackToChat = onBackToChat, - modifier = Modifier.weight(1f) - ) - 1 -> TransactionHistory( - transactions = walletViewModel.getAllTransactions().observeAsState(initial = emptyList()).value, - modifier = Modifier.weight(1f), - onTransactionClick = { transaction -> - // For lightning receive transactions, open the receive view with the quote - if (transaction.type == com.bitchat.android.wallet.data.TransactionType.LIGHTNING_RECEIVE && - transaction.quote != null) { - walletViewModel.setCurrentMintQuote(transaction.quote!!) - showReceiveView = true + Box(modifier = modifier.fillMaxSize()) { + // Main content + if (showReceiveView) { + // Full-screen ReceiveView + ReceiveView( + viewModel = walletViewModel, + onNavigateBack = { + showReceiveView = false + walletViewModel.hideReceiveDialog() + } + ) + } else if (showSendView) { + // Full-screen SendView + SendView( + viewModel = walletViewModel, + onNavigateBack = { + showSendView = false + walletViewModel.hideSendDialog() + } + ) + } else { + Column(modifier = Modifier.fillMaxSize()) { + // Content + when (selectedTab) { + 0 -> WalletOverview( + viewModel = walletViewModel, + onBackToChat = onBackToChat, + modifier = Modifier.weight(1f) + ) + 1 -> TransactionHistory( + transactions = walletViewModel.getAllTransactions().observeAsState(initial = emptyList()).value, + modifier = Modifier.weight(1f), + onTransactionClick = { transaction -> + // For lightning receive transactions, open the receive view with the quote + if (transaction.type == com.bitchat.android.wallet.data.TransactionType.LIGHTNING_RECEIVE && + transaction.quote != null) { + walletViewModel.setCurrentMintQuote(transaction.quote!!) + showReceiveView = true + } } - } - ) - 2 -> MintsScreen(viewModel = walletViewModel, modifier = Modifier.weight(1f)) - 3 -> WalletSettings( - viewModel = walletViewModel, - onBackClick = { /* No back action needed in tab navigation */ } + ) + 2 -> MintsScreen(viewModel = walletViewModel, modifier = Modifier.weight(1f)) + 3 -> WalletSettings( + viewModel = walletViewModel, + onBackClick = { /* No back action needed in tab navigation */ } + ) + } + + // Bottom Navigation + WalletBottomNavigation( + selectedTab = selectedTab, + onTabSelected = { selectedTab = it } ) } - - // Bottom Navigation - WalletBottomNavigation( - selectedTab = selectedTab, - onTabSelected = { selectedTab = it } + } + + // Success animation overlay + if (showSuccessAnimation && successAnimationData != null) { + SuccessAnimation( + animationData = successAnimationData!!, + onAnimationComplete = { + walletViewModel.hideSuccessAnimation() + }, + modifier = Modifier.zIndex(10f) ) } } - // Handle dialog states - if (showSendDialog) { - LaunchedEffect(showSendDialog) { - if (showSendDialog) { - showSendView = true - } + // Handle dialog states - sync local view state with ViewModel dialog state + LaunchedEffect(showSendDialog) { + if (showSendDialog) { + showSendView = true + } else { + showSendView = false } } - if (showReceiveDialog) { - LaunchedEffect(showReceiveDialog) { - if (showReceiveDialog) { - showReceiveView = true - } + LaunchedEffect(showReceiveDialog) { + if (showReceiveDialog) { + showReceiveView = true + } else { + showReceiveView = false } } 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 8ca17495..ef65dc71 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 @@ -82,12 +82,40 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) private val _currentMeltQuote = MutableLiveData(null) val currentMeltQuote: LiveData = _currentMeltQuote + // Token input state for receive dialog + private val _tokenInput = MutableLiveData("") + val tokenInput: LiveData = _tokenInput + + // Success animation state + private val _showSuccessAnimation = MutableLiveData(false) + val showSuccessAnimation: LiveData = _showSuccessAnimation + + private val _successAnimationData = MutableLiveData(null) + val successAnimationData: LiveData = _successAnimationData + // State management private var pollingJob: kotlinx.coroutines.Job? = null enum class SendType { CASHU, LIGHTNING } enum class ReceiveType { CASHU, LIGHTNING } + /** + * Data for success animation display + */ + data class SuccessAnimationData( + val type: SuccessAnimationType, + val amount: Long, + val unit: String, + val description: String + ) + + enum class SuccessAnimationType { + CASHU_RECEIVED, + CASHU_SENT, + LIGHTNING_RECEIVED, + LIGHTNING_SENT + } + init { loadInitialData() startPolling() @@ -359,6 +387,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) _decodedToken.value = null _currentMintQuote.value = null _receiveType.value = ReceiveType.CASHU + clearTokenInput() } fun setSendType(type: SendType) { @@ -381,6 +410,30 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) _errorMessage.value = null } + // Token input management + fun setTokenInput(token: String) { + _tokenInput.value = token + if (token.isNotEmpty() && token.startsWith("cashu")) { + decodeCashuToken(token) + } + } + + fun clearTokenInput() { + _tokenInput.value = "" + _decodedToken.value = null + } + + // Success animation management + fun showSuccessAnimation(animationData: SuccessAnimationData) { + _successAnimationData.value = animationData + _showSuccessAnimation.value = true + } + + fun hideSuccessAnimation() { + _showSuccessAnimation.value = false + _successAnimationData.value = null + } + // Back navigation handler private var backHandler: (() -> Boolean)? = null @@ -488,7 +541,23 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) repository.saveTransaction(transaction).onSuccess { loadTransactions() refreshBalance() + + // Clear token input immediately + clearTokenInput() + + // Show success animation + val animationData = SuccessAnimationData( + type = SuccessAnimationType.CASHU_RECEIVED, + amount = amount, + unit = "sat", + description = "Cashu token received successfully!" + ) + showSuccessAnimation(animationData) + + // Close receive dialog after animation starts + delay(500) // Short delay to let animation start hideReceiveDialog() + }.onFailure { error -> Log.e(TAG, "Failed to save transaction", error) _errorMessage.value = "Failed to save transaction: ${error.message}" @@ -797,6 +866,47 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) } } + // Utility function to format sats + private fun formatSats(sats: Long): String { + return when { + sats >= 100_000_000 -> String.format("%.2f BTC", sats / 100_000_000.0) + sats >= 1000 -> String.format("%,d sats", sats) + else -> "$sats sats" + } + } + + /** + * Open receive dialog with a pre-filled Cashu token (for external integrations like chat) + */ + fun openReceiveDialogWithToken(tokenString: String) { + viewModelScope.launch { + try { + // Clear any existing state first + hideReceiveDialog() + _tokenInput.value = "" + _decodedToken.value = null + + // Set the receive type to Cashu + _receiveType.value = ReceiveType.CASHU + + // Set the token input which will trigger decoding + _tokenInput.value = tokenString + + // Decode the token + decodeCashuToken(tokenString) + + // Show the receive dialog + _showReceiveDialog.value = true + + Log.d(TAG, "Opened receive dialog with token: ${tokenString.take(20)}...") + + } catch (e: Exception) { + Log.e(TAG, "Error opening receive dialog with token", e) + _errorMessage.value = "Failed to process token: ${e.message}" + } + } + } + /** * Get full transaction history (not just last 10) */