This commit is contained in:
callebtc
2025-07-15 15:39:06 +02:00
parent 13b673226c
commit 51736bb47b
6 changed files with 414 additions and 71 deletions
@@ -51,7 +51,8 @@ import java.util.*
@Composable @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel, viewModel: ChatViewModel,
onWalletClick: () -> Unit = {} onWalletClick: () -> Unit = {},
onWalletClickWithToken: ((String) -> Unit)? = null
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList()) val messages by viewModel.messages.observeAsState(emptyList())
@@ -109,11 +110,9 @@ fun ChatScreen(
messages = displayMessages, messages = displayMessages,
currentUserNickname = nickname, currentUserNickname = nickname,
meshService = viewModel.meshService, meshService = viewModel.meshService,
onCashuPaymentClick = { token -> onCashuPaymentClick = { parsedToken ->
// Open wallet with the receive dialog pre-filled with this token // Open wallet with the receive dialog pre-filled with this token
onWalletClick() onWalletClickWithToken?.invoke(parsedToken.originalString) ?: onWalletClick()
// TODO: We need to pass the token to the wallet somehow
// This could be done through a shared state, intent, or callback
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -54,7 +54,12 @@ fun MainAppScreen(
when (selectedTab) { when (selectedTab) {
0 -> ChatScreen( 0 -> ChatScreen(
viewModel = chatViewModel, 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( 1 -> WalletScreen(
walletViewModel = walletViewModel, walletViewModel = walletViewModel,
@@ -174,7 +174,7 @@ private fun CashuReceiveContent(
decodedToken: CashuToken?, decodedToken: CashuToken?,
isLoading: Boolean isLoading: Boolean
) { ) {
var token by remember { mutableStateOf("") } val token by viewModel.tokenInput.observeAsState("")
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
if (decodedToken != null) { if (decodedToken != null) {
@@ -302,10 +302,7 @@ private fun CashuReceiveContent(
OutlinedTextField( OutlinedTextField(
value = token, value = token,
onValueChange = { onValueChange = {
token = it viewModel.setTokenInput(it)
if (it.isNotEmpty() && it.startsWith("cashu")) {
viewModel.decodeCashuToken(it)
}
}, },
label = { label = {
Text( Text(
@@ -354,8 +351,7 @@ private fun CashuReceiveContent(
onClick = { onClick = {
clipboardManager.getText()?.text?.let { clipText -> clipboardManager.getText()?.text?.let { clipText ->
if (clipText.startsWith("cashu")) { if (clipText.startsWith("cashu")) {
token = clipText viewModel.setTokenInput(clipText)
viewModel.decodeCashuToken(clipText)
} }
} }
}, },
@@ -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"
}
}
@@ -13,9 +13,12 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.wallet.viewmodel.WalletViewModel import com.bitchat.android.wallet.viewmodel.WalletViewModel
// Import the SuccessAnimation component (same package, no need for full path)
/** /**
* Main wallet screen with bottom navigation * Main wallet screen with bottom navigation
*/ */
@@ -30,6 +33,8 @@ fun WalletScreen(
var showSendView by remember { mutableStateOf(false) } var showSendView by remember { mutableStateOf(false) }
val showSendDialog by walletViewModel.showSendDialog.observeAsState(false) val showSendDialog by walletViewModel.showSendDialog.observeAsState(false)
val showReceiveDialog by walletViewModel.showReceiveDialog.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 // Back handler for the wallet
fun handleBackPress(): Boolean { fun handleBackPress(): Boolean {
@@ -64,74 +69,88 @@ fun WalletScreen(
} }
} }
if (showReceiveView) { Box(modifier = modifier.fillMaxSize()) {
// Full-screen ReceiveView // Main content
ReceiveView( if (showReceiveView) {
viewModel = walletViewModel, // Full-screen ReceiveView
onNavigateBack = { ReceiveView(
showReceiveView = false viewModel = walletViewModel,
walletViewModel.hideReceiveDialog() onNavigateBack = {
} showReceiveView = false
) walletViewModel.hideReceiveDialog()
} else if (showSendView) { }
// Full-screen SendView )
SendView( } else if (showSendView) {
viewModel = walletViewModel, // Full-screen SendView
onNavigateBack = { SendView(
showSendView = false viewModel = walletViewModel,
walletViewModel.hideSendDialog() onNavigateBack = {
} showSendView = false
) walletViewModel.hideSendDialog()
} else { }
Column(modifier = modifier.fillMaxSize()) { )
// Content } else {
when (selectedTab) { Column(modifier = Modifier.fillMaxSize()) {
0 -> WalletOverview( // Content
viewModel = walletViewModel, when (selectedTab) {
onBackToChat = onBackToChat, 0 -> WalletOverview(
modifier = Modifier.weight(1f) viewModel = walletViewModel,
) onBackToChat = onBackToChat,
1 -> TransactionHistory( modifier = Modifier.weight(1f)
transactions = walletViewModel.getAllTransactions().observeAsState(initial = emptyList()).value, )
modifier = Modifier.weight(1f), 1 -> TransactionHistory(
onTransactionClick = { transaction -> transactions = walletViewModel.getAllTransactions().observeAsState(initial = emptyList()).value,
// For lightning receive transactions, open the receive view with the quote modifier = Modifier.weight(1f),
if (transaction.type == com.bitchat.android.wallet.data.TransactionType.LIGHTNING_RECEIVE && onTransactionClick = { transaction ->
transaction.quote != null) { // For lightning receive transactions, open the receive view with the quote
walletViewModel.setCurrentMintQuote(transaction.quote!!) if (transaction.type == com.bitchat.android.wallet.data.TransactionType.LIGHTNING_RECEIVE &&
showReceiveView = true transaction.quote != null) {
walletViewModel.setCurrentMintQuote(transaction.quote!!)
showReceiveView = true
}
} }
} )
) 2 -> MintsScreen(viewModel = walletViewModel, modifier = Modifier.weight(1f))
2 -> MintsScreen(viewModel = walletViewModel, modifier = Modifier.weight(1f)) 3 -> WalletSettings(
3 -> WalletSettings( viewModel = walletViewModel,
viewModel = walletViewModel, onBackClick = { /* No back action needed in tab navigation */ }
onBackClick = { /* No back action needed in tab navigation */ } )
}
// Bottom Navigation
WalletBottomNavigation(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
) )
} }
}
// Bottom Navigation
WalletBottomNavigation( // Success animation overlay
selectedTab = selectedTab, if (showSuccessAnimation && successAnimationData != null) {
onTabSelected = { selectedTab = it } SuccessAnimation(
animationData = successAnimationData!!,
onAnimationComplete = {
walletViewModel.hideSuccessAnimation()
},
modifier = Modifier.zIndex(10f)
) )
} }
} }
// Handle dialog states // Handle dialog states - sync local view state with ViewModel dialog state
if (showSendDialog) { LaunchedEffect(showSendDialog) {
LaunchedEffect(showSendDialog) { if (showSendDialog) {
if (showSendDialog) { showSendView = true
showSendView = true } else {
} showSendView = false
} }
} }
if (showReceiveDialog) { LaunchedEffect(showReceiveDialog) {
LaunchedEffect(showReceiveDialog) { if (showReceiveDialog) {
if (showReceiveDialog) { showReceiveView = true
showReceiveView = true } else {
} showReceiveView = false
} }
} }
@@ -82,12 +82,40 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
private val _currentMeltQuote = MutableLiveData<MeltQuote?>(null) private val _currentMeltQuote = MutableLiveData<MeltQuote?>(null)
val currentMeltQuote: LiveData<MeltQuote?> = _currentMeltQuote val currentMeltQuote: LiveData<MeltQuote?> = _currentMeltQuote
// Token input state for receive dialog
private val _tokenInput = MutableLiveData<String>("")
val tokenInput: LiveData<String> = _tokenInput
// Success animation state
private val _showSuccessAnimation = MutableLiveData<Boolean>(false)
val showSuccessAnimation: LiveData<Boolean> = _showSuccessAnimation
private val _successAnimationData = MutableLiveData<SuccessAnimationData?>(null)
val successAnimationData: LiveData<SuccessAnimationData?> = _successAnimationData
// State management // State management
private var pollingJob: kotlinx.coroutines.Job? = null private var pollingJob: kotlinx.coroutines.Job? = null
enum class SendType { CASHU, LIGHTNING } enum class SendType { CASHU, LIGHTNING }
enum class ReceiveType { 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 { init {
loadInitialData() loadInitialData()
startPolling() startPolling()
@@ -359,6 +387,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
_decodedToken.value = null _decodedToken.value = null
_currentMintQuote.value = null _currentMintQuote.value = null
_receiveType.value = ReceiveType.CASHU _receiveType.value = ReceiveType.CASHU
clearTokenInput()
} }
fun setSendType(type: SendType) { fun setSendType(type: SendType) {
@@ -381,6 +410,30 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
_errorMessage.value = null _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 // Back navigation handler
private var backHandler: (() -> Boolean)? = null private var backHandler: (() -> Boolean)? = null
@@ -488,7 +541,23 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
repository.saveTransaction(transaction).onSuccess { repository.saveTransaction(transaction).onSuccess {
loadTransactions() loadTransactions()
refreshBalance() 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() hideReceiveDialog()
}.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}" _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) * Get full transaction history (not just last 10)
*/ */