This commit is contained in:
callebtc
2025-07-15 16:16:51 +02:00
parent df377b8ee6
commit 1ba14a00d4
8 changed files with 621 additions and 17 deletions
@@ -34,6 +34,9 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.parsing.ParsedCashuToken
import com.bitchat.android.ui.payment.PaymentStatusIndicator
import com.bitchat.android.ui.payment.PaymentStatus
import com.bitchat.android.wallet.viewmodel.WalletViewModel
import java.text.SimpleDateFormat
import java.util.*
@@ -51,9 +54,17 @@ import java.util.*
@Composable
fun ChatScreen(
viewModel: ChatViewModel,
walletViewModel: WalletViewModel? = null,
onWalletClick: () -> Unit = {},
onWalletClickWithToken: ((com.bitchat.android.parsing.ParsedCashuToken) -> Unit)? = null
) {
// Initialize payment manager when wallet ViewModel is available
LaunchedEffect(walletViewModel) {
walletViewModel?.let { wallet ->
viewModel.initializePaymentManager(wallet)
}
}
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
@@ -70,6 +81,9 @@ fun ChatScreen(
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
// Observe payment status
val paymentStatus by viewModel.getPaymentStatus().collectAsState()
var messageText by remember { mutableStateOf("") }
var showPasswordPrompt by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) }
@@ -139,7 +153,9 @@ fun ChatScreen(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme
colorScheme = colorScheme,
paymentStatus = paymentStatus,
onClearPaymentStatus = { viewModel.clearPaymentStatus() }
)
}
@@ -214,7 +230,9 @@ private fun ChatInputSection(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
colorScheme: ColorScheme
colorScheme: ColorScheme,
paymentStatus: PaymentStatus?,
onClearPaymentStatus: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
@@ -235,6 +253,13 @@ private fun ChatInputSection(
Divider(color = colorScheme.outline.copy(alpha = 0.2f))
}
// Payment status indicator
PaymentStatusIndicator(
status = paymentStatus,
onDismiss = onClearPaymentStatus,
modifier = Modifier.fillMaxWidth()
)
MessageInput(
value = messageText,
onValueChange = onMessageTextChange,
@@ -6,19 +6,25 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryAck
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.ui.payment.PaymentManager
import com.bitchat.android.ui.payment.PaymentStatus
import com.bitchat.android.wallet.viewmodel.WalletViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import java.util.*
import kotlin.random.Random
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
* ViewModel for managing the chat functionality
* Refactored to use composition over inheritance for better code organization
*/
class ChatViewModel(
application: Application,
@@ -38,6 +44,14 @@ class ChatViewModel(
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val notificationManager = NotificationManager(application.applicationContext)
// Payment manager (lazily initialized with wallet ViewModel)
private var paymentManager: PaymentManager? = null
// Initialize payment manager with wallet ViewModel
fun initializePaymentManager(walletViewModel: WalletViewModel) {
paymentManager = PaymentManager(viewModelScope, walletViewModel)
}
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
state = state,
@@ -170,31 +184,39 @@ class ChatViewModel(
}
// MARK: - Message Sending
fun sendMessage(content: String) {
if (content.isEmpty()) return
// Check for commands
if (content.startsWith("/")) {
commandProcessor.processCommand(content, meshService, meshService.myPeerID) { messageContent, mentions, channel ->
meshService.sendMessage(messageContent, mentions, channel)
}
commandProcessor.processCommand(
command = content,
meshService = meshService,
myPeerID = meshService.myPeerID,
onSendMessage = { messageContent, mentions, channel ->
meshService.sendMessage(messageContent, mentions, channel)
},
onPaymentRequest = { amount ->
handlePaymentRequest(amount)
}
)
return
}
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
val channels = messageManager.parseChannels(content)
// Auto-join mentioned channels
channels.forEach { channel ->
if (!state.getJoinedChannelsValue().contains(channel)) {
joinChannel(channel)
}
}
val selectedPeer = state.getSelectedPrivateChatPeerValue()
val currentChannelValue = state.getCurrentChannelValue()
if (selectedPeer != null) {
// Send private message
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
@@ -248,6 +270,72 @@ class ChatViewModel(
}
}
// MARK: - Payment Management
/**
* Handle payment request from /pay command
*/
private fun handlePaymentRequest(amount: Long) {
paymentManager?.createPayment(amount) { token ->
// Send the created token to the current chat
sendCashuTokenToChat(token)
}
}
/**
* Send a Cashu token to the current chat
*/
private fun sendCashuTokenToChat(token: String) {
val selectedPeer = state.getSelectedPrivateChatPeerValue()
val currentChannelValue = state.getCurrentChannelValue()
if (selectedPeer != null) {
// Send token as private message
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
privateChatManager.sendPrivateMessage(
token,
selectedPeer,
recipientNickname,
state.getNicknameValue(),
meshService.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
}
} else {
// Send token as public/channel message
val message = BitchatMessage(
sender = state.getNicknameValue() ?: meshService.myPeerID,
content = token,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
channel = currentChannelValue
)
if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
meshService.sendMessage(token, emptyList(), currentChannelValue)
} else {
messageManager.addMessage(message)
meshService.sendMessage(token, emptyList(), null)
}
}
}
/**
* Get payment status for UI
*/
fun getPaymentStatus(): StateFlow<PaymentStatus?> {
return paymentManager?.paymentStatus ?: kotlinx.coroutines.flow.MutableStateFlow(null).asStateFlow()
}
/**
* Clear payment status
*/
fun clearPaymentStatus() {
paymentManager?.clearStatus()
}
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
@@ -21,6 +21,7 @@ class CommandProcessor(
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
CommandSuggestion("/pay", emptyList(), "<amount>", "send Cashu payment"),
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
CommandSuggestion("/w", emptyList(), null, "see who's online")
@@ -28,7 +29,13 @@ class CommandProcessor(
// MARK: - Command Processing
fun processCommand(command: String, meshService: Any, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean {
fun processCommand(
command: String,
meshService: Any,
myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit,
onPaymentRequest: ((Long) -> Unit)? = null
): Boolean {
if (!command.startsWith("/")) return false
val parts = command.split(" ")
@@ -45,6 +52,7 @@ class CommandProcessor(
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
"/channels" -> handleChannelsCommand()
"/pay" -> handlePayCommand(parts, onPaymentRequest)
else -> handleUnknownCommand(cmd)
}
@@ -316,6 +324,47 @@ class CommandProcessor(
messageManager.addMessage(systemMessage)
}
private fun handlePayCommand(parts: List<String>, onPaymentRequest: ((Long) -> Unit)?) {
if (parts.size < 2) {
val systemMessage = BitchatMessage(
sender = "system",
content = "usage: /pay <amount> - send Cashu payment in sats",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return
}
val amountStr = parts[1]
val amount = amountStr.toLongOrNull()
if (amount == null || amount <= 0) {
val systemMessage = BitchatMessage(
sender = "system",
content = "invalid amount: '$amountStr'. please enter a positive number of sats.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return
}
if (amount > 1_000_000) {
val systemMessage = BitchatMessage(
sender = "system",
content = "amount too large: $amount sats. maximum is 1,000,000 sats.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return
}
// Trigger payment creation
onPaymentRequest?.invoke(amount)
}
private fun handleUnknownCommand(cmd: String) {
val systemMessage = BitchatMessage(
sender = "system",
@@ -58,6 +58,7 @@ fun MainAppScreen(
// Chat is always the base view
ChatScreen(
viewModel = chatViewModel,
walletViewModel = walletViewModel,
onWalletClick = { showWallet = true },
onWalletClickWithToken = { parsedToken ->
// Open wallet and show receive dialog with the parsed token immediately
@@ -0,0 +1,93 @@
package com.bitchat.android.ui.payment
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.bitchat.android.wallet.viewmodel.WalletViewModel
import com.bitchat.android.wallet.data.TransactionType
import com.bitchat.android.wallet.data.TransactionStatus
import com.bitchat.android.wallet.data.WalletTransaction
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.*
/**
* Manages payment creation and status for the /pay command
* Coordinates between ChatViewModel, WalletViewModel, and UI
*/
class PaymentManager(
private val coroutineScope: CoroutineScope,
private val walletViewModel: WalletViewModel
) {
companion object {
private const val TAG = "PaymentManager"
}
// Payment status state
private val _paymentStatus = MutableStateFlow<PaymentStatus?>(null)
val paymentStatus: StateFlow<PaymentStatus?> = _paymentStatus.asStateFlow()
/**
* Create a Cashu token payment for the specified amount
*/
fun createPayment(
amount: Long,
onTokenCreated: (String) -> Unit
) {
Log.d(TAG, "Creating payment for $amount sats")
// Set creating status
_paymentStatus.value = PaymentStatus.Creating(amount)
coroutineScope.launch {
try {
// Use WalletViewModel's createCashuToken method
walletViewModel.createCashuTokenForPayment(
amount = amount,
memo = "Payment via /pay command",
onSuccess = { token ->
Log.d(TAG, "Payment token created successfully: ${token.take(20)}...")
// Set success status
_paymentStatus.value = PaymentStatus.Success(amount, token)
// Notify caller with the token
onTokenCreated(token)
},
onError = { error ->
Log.e(TAG, "Payment creation failed: $error")
// Set error status
_paymentStatus.value = PaymentStatus.Error(amount, error)
}
)
} catch (e: Exception) {
Log.e(TAG, "Exception creating payment", e)
_paymentStatus.value = PaymentStatus.Error(
amount,
e.message ?: "Unknown error occurred"
)
}
}
}
/**
* Clear the current payment status
*/
fun clearStatus() {
_paymentStatus.value = null
}
/**
* Check if a payment is currently being created
*/
fun isCreatingPayment(): Boolean {
return _paymentStatus.value is PaymentStatus.Creating
}
}
@@ -0,0 +1,298 @@
package com.bitchat.android.ui.payment
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.clip
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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
/**
* Payment status for Cashu token creation
*/
sealed class PaymentStatus {
data class Creating(val amount: Long) : PaymentStatus()
data class Success(val amount: Long, val token: String) : PaymentStatus()
data class Error(val amount: Long, val message: String) : PaymentStatus()
}
/**
* Sleek payment status indicator that slides up above the text input
* Shows creation progress, success, and error states
*/
@Composable
fun PaymentStatusIndicator(
status: PaymentStatus?,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val isVisible = status != null
// Auto-dismiss success/error states after delay
LaunchedEffect(status) {
if (status is PaymentStatus.Success || status is PaymentStatus.Error) {
delay(3000) // Show for 3 seconds
onDismiss()
}
}
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = spring(
dampingRatio = 0.8f,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(
animationSpec = tween(300, easing = EaseOutCubic)
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = spring(
dampingRatio = 0.9f,
stiffness = Spring.StiffnessMedium
)
) + fadeOut(
animationSpec = tween(200, easing = EaseInCubic)
),
modifier = modifier
) {
if (status != null) {
PaymentStatusCard(status = status)
}
}
}
@Composable
private fun PaymentStatusCard(status: PaymentStatus) {
val colors = when (status) {
is PaymentStatus.Creating -> PaymentColors(
background = Color(0xFF1A1A1A),
border = Color(0xFF00C851),
icon = Color(0xFF00C851),
text = Color.White
)
is PaymentStatus.Success -> PaymentColors(
background = Color(0xFF00C851).copy(alpha = 0.15f),
border = Color(0xFF00C851),
icon = Color(0xFF00C851),
text = Color.White
)
is PaymentStatus.Error -> PaymentColors(
background = Color(0xFFFF4444).copy(alpha = 0.15f),
border = Color(0xFFFF4444),
icon = Color(0xFFFF4444),
text = Color.White
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = colors.background
),
border = androidx.compose.foundation.BorderStroke(2.dp, colors.border)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Status icon with animation
PaymentStatusIcon(
status = status,
color = colors.icon
)
// Status content
Column(
modifier = Modifier.weight(1f)
) {
PaymentStatusText(
status = status,
textColor = colors.text
)
}
// Amount display
PaymentAmountDisplay(
amount = when (status) {
is PaymentStatus.Creating -> status.amount
is PaymentStatus.Success -> status.amount
is PaymentStatus.Error -> status.amount
},
color = colors.text
)
}
}
}
@Composable
private fun PaymentStatusIcon(
status: PaymentStatus,
color: Color
) {
when (status) {
is PaymentStatus.Creating -> {
// Animated spinner
val infiniteTransition = rememberInfiniteTransition(label = "payment_creating")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "spinner_rotation"
)
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Creating payment",
tint = color,
modifier = Modifier
.size(24.dp)
.graphicsLayer { rotationZ = rotation }
)
}
is PaymentStatus.Success -> {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Payment sent",
tint = color,
modifier = Modifier.size(24.dp)
)
}
is PaymentStatus.Error -> {
Icon(
imageVector = Icons.Filled.Error,
contentDescription = "Payment failed",
tint = color,
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
private fun PaymentStatusText(
status: PaymentStatus,
textColor: Color
) {
when (status) {
is PaymentStatus.Creating -> {
Text(
text = "Creating payment...",
color = textColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
Text(
text = "Generating Cashu token",
color = textColor.copy(alpha = 0.7f),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
is PaymentStatus.Success -> {
Text(
text = "Payment sent!",
color = textColor,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Text(
text = "Cashu token delivered to chat",
color = textColor.copy(alpha = 0.8f),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
is PaymentStatus.Error -> {
Text(
text = "Payment failed",
color = textColor,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Text(
text = status.message,
color = textColor.copy(alpha = 0.8f),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
@Composable
private fun PaymentAmountDisplay(
amount: Long,
color: Color
) {
Column(
horizontalAlignment = Alignment.End
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "",
fontSize = 16.sp,
color = color
)
Text(
text = formatSats(amount),
color = color,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
/**
* Colors for different payment states
*/
private data class PaymentColors(
val background: Color,
val border: Color,
val icon: Color,
val text: Color
)
/**
* Format sats amount for display
*/
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"
}
}
@@ -284,8 +284,10 @@ class CashuService {
Log.d(TAG, "Token validation successful, amount: $amount")
wallet!!.receive(token)
val ffiAmount = wallet!!.receive(token)
Log.d(TAG, "FFI amount: ${ffiAmount}")
Result.success(amount)
} catch (e: FfiException) {
@@ -526,6 +526,54 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
}
/**
* Create a Cashu token for payments (with callbacks for /pay command)
*/
fun createCashuTokenForPayment(
amount: Long,
memo: String? = null,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) {
viewModelScope.launch {
try {
cashuService.createToken(amount, memo).onSuccess { token ->
// Add transaction
val transaction = WalletTransaction(
id = UUID.randomUUID().toString(),
type = TransactionType.CASHU_SEND,
amount = BigDecimal(amount),
unit = "sat",
status = TransactionStatus.CONFIRMED,
timestamp = Date(),
description = memo ?: "Cashu token sent",
token = token
)
repository.saveTransaction(transaction).onSuccess {
loadTransactions()
refreshBalance()
// Success callback with token
onSuccess(token)
}.onFailure { error ->
Log.e(TAG, "Failed to save transaction", error)
onError("Failed to save transaction: ${error.message}")
}
}.onFailure { error ->
onError("Failed to create token: ${error.message}")
}
} catch (e: Exception) {
Log.e(TAG, "Exception creating payment token", e)
onError(e.message ?: "Unknown error occurred")
}
}
}
/**
* Decode a Cashu token to show information
*/